Статы — фундаментальный элемент игрового дизайна, который определяет характеристики персонажей, предметов и других объектов в игровом мире. Будь то показатель силы в RPG, скорость передвижения в платформере или урон оружия в шутере — все эти параметры являются частью системы статов. Хорошо спроектированная система статов может стать каркасом, на котором держится вся игровая механика, позволяя создавать глубокие, сбалансированные и увлекательные игровые процессы. В игровой разработки на Unity создание гибкой и эффективной системы статов часто превращается в нетривиальную задачу. Разработчики сталкиваются с проблемой выбора между простотой реализации и функциональной гибкостью, между производительностью и расширяемостью. Ключевая проблема заключается в создании такой архитектуры, которая сможет легко масштабироваться вместе с ростом проекта, адаптироваться к новым требованиям и при этом оставаться понятной для сопровождения.
Частые ограничения традиционных подходов включают жесткую связанность компонентов, сложность добавления новых типов модификаторов и непрозрачную логику вычислений. Когда игра разрастается, эти проблемы могут превратиться в настоящую головную боль. Представьте ситуацию: вам нужно добавить новый эффект, который уменьшает все входящие характеристики на процент от базового значения — казалось бы, простая задача, но в плохо спроектированной системе она может потребовать переписывания существующего кода и создания новых костылей.
При разработке системы статов крайне важно учитывать принцип открытости-закрытости. Система должна быть закрыта для изменений, но открыта для расширения. Это значит, что добавление новых типов модификаторов, новых способов расчета и других элементов не должно требовать изменения существующего кода. Такой подход обеспечивает устойчивость системы к изменениям и снижает риск появления новых багов при доработке функционала.
Разработка эффективной системы статов в Unity: от концепции к реализации
Современные системы статов прошли долгий путь эволюции от простых числовых значений до сложных взаимозависимых систем с множеством модификаторов и эффектов. В ранних играх статы часто были просто константами, жестко закодированными в игровой логике. С развитием игр и ростом их сложности возникла потребность в более гибких решениях, способных учитывать временные эффекты, комбинирование характеристик и сложные формулы расчета.
Стоит обратить внимание на базовые механизмы расчета статов. В игровой разработке широко используются два основных типа модификаторов: аддитивные и мультипликативные. Аддитивные модификаторы просто добавляют определенное значение к базовому показателю. Например если у персонажа базовая сила равна 10, а модификатор от экипировки дает +5 к силе, итоговое значение составит 15. Мультипликативные модификаторы, в свою очередь, умножают текущее значение на определенный коэффициент. Если взять тот же пример с базовой силой 10 и применить мультипликативный модификатор 1.2 (увеличение на 20%), итоговое значение будет 12. Комбинирование этих модификаторов и определение порядка их применения — ключевые аспекты разработки системы статов.
Психологический аспект восприятия статов игроками часто недооценивают. Игроки реагируют на числовые значения не только рационально, но и эмоционально. Например, увеличение урона на 5 единиц воспринимается совершенно иначе, чем увеличение на 5%. Крупные числа могут создавать ощущение мощи, даже если процентное увеличение относительно невелико. Этот психологический фактор нельзя игнорировать при проектировании системы статов и особенно при разработке интерфейса для отображения характеристик.
Помимо основных типов модификаторов, существуют и более сложные подходы. Например, в некоторых играх используются конверсионные модификаторы, которые преобразуют один стат в другой (скажем, 10% интеллекта добавляется к силе заклинаний). Также встречаются условные модификаторы, срабатывающие только при определенных условиях: бонус к скорости, если здоровье ниже 50%, или увеличение урона по ослабленным противникам.
Порядок применения модификаторов критически важен для конечного результата вычислений. Обычно плоские (фиксированные) модификаторы применяются сначала, затем идут аддитивные процентные, и в конце — мультипликативные. Если изменить этот порядок, итоговое значение может существенно отличаться. Например, если у нас есть базовый урон 100, плоский бонус +20 и мультипликативный модификатор ×1.5, то при "правильном" порядке расчета мы получим (100 + 20) × 1.5 = 180, а при обратном порядке — 100 + (20 × 1.5) = 130.
Разработка сбалансированной системы статов требует тщательного учета взаимодействия всех элементов. Одним из ключевых принципов является предсказуемость — игрок должен понимать, как его решения влияют на характеристики персонажа. Конечно, не стоит перегружать игрока избыточной информацией, но базовая логика должна быть интуитивно понятной. Второй принцип — масштабируемость. Система должна хорошо работать как с маленькими, так и с большими значениями, не создавая ситуаций, когда определенные характеристики становятся бесполезными или, наоборот, критически важными. Например, в некоторых RPG критический шанс становится настолько высоким на поздних уровнях, что обычные атаки практически не используются, что обедняет геймплей. Третий принцип — баланс между специализацией и универсальностью. Игроки должны иметь возможность создавать персонажей, сфокусированных на конкретных аспектах игры, но при этом такая специализация не должна делать их беспомощными в других ситуациях. Это особенно актуально для мультиплеерных игр, где разнообразие стилей игры критически важно для долговременного интереса аудитории.
В современных играх системы статов часто выходят за рамки числовых показателей и включают качественные характеристики, влияющие на игровой процесс нестандартными способами. Например, в играх с процедурной генерацией могут использоваться параметры, влияющие на вероятность появления определенных эффектов или модифицирующие поведение ИИ противников.
Интересный аспект — рекурсивные зависимости между статами. Например, интеллект может увеличивать ману, которая в свою очередь влияет на силу заклинаний, а сила заклинаний может давать бонус к интеллекту через определенные способности. Такие взаимосвязи создают глубину игровой механики, но требуют тщательного планирования, чтобы избежать бесконечных циклов расчета или непредвиденных скачков значений.
С психологической точки зрения, игроки по-разному реагируют на абсолютные и относительные изменения. Исследования показывают, что люди склонны переоценивать эффект от небольших процентных изменений и недооценивать большие. Это влияет на дизайн системы прогрессии — ранние уровни обычно дают заметные абсолютные прибавки, в то время как поздние уровни часто фокусируются на процентных бонусах, которые фактически дают больший прирост, но воспринимаются как менее значимые. Также стоит учитывать эффект "кванта удовольствия" — игроки получают больше удовлетворения от частых небольших улучшений, чем от редких крупных. Это один из факторов, почему во многих играх характеристики растут с каждым уровнем, а не через определенные интервалы. Сам процесс улучшения персонажа становится источником позитивных эмоций, что удерживает игроков в игре.
Не менее важным аспектом является визуальное представление статов. Исследования в области UX показывают, что способ отображения числовых данных существенно влияет на их восприятие. Графики, полосы прогресса, цветовое кодирование — все эти элементы помогают игрокам быстрее интерпретировать информацию и принимать более обдуманные решения.
Системы статов в современных играх часто имеют несколько слоев абстракции. На нижнем уровне находятся базовые характеристики (сила, ловкость, интеллект), на среднем — производные показатели (урон, уклонение, скорость восстановления), а на верхнем — практические возможности персонажа (доступ к определенным действиям, оружию, заклинаниям). Такая иерархия позволяет создавать сложные взаимосвязи с понятной для игрока структурой. Каждая игра требует индивидуального подхода к проектированию системы статов. То, что работает в MMORPG, может оказаться избыточным для казуальной мобильной игры. Важно опираться на жанровые конвенции, но не бояться экспериментировать с новыми механиками, если они лучше соответствуют геймплейным целям проекта.
Передача значений по ip unity -> unity Доброго времени суток
вопрос: (мб простой) как передать например string значение между двумя unity... Unity сцены. Unity lifecycle Всем привет.
Не понимаю по каким словам искать ответ на этот вопрос. Не совсем понимаю жизненный... Где можно почитать основы разработки в Unity/Unity 3D До этого был небольшой опыт работы с Windows.Forms и WFP с C#. Где можно разобраться и научится... Установка бесплатной Unity Personal с сайта Unity Делаю так:
Выбор Версии Personal здесь:...
Архитектурные подходы
При проектировании системы статов первый вопрос, который встает перед разработчиком: использовать монолитную или модульную архитектуру? Монолитный подход предлагает создание единого класса, который обрабатывает все возможные типы модификаторов и их взаимодействия. Такое решение может быть быстрее в реализации, но существенно проигрывает в долгосрочной перспективе, когда возникает необходимость добавления новых типов модификаторов или изменения логики расчетов. Модульный подход предполагает разделение ответственности между несколькими классами, каждый из которых отвечает за конкретную функцию. Например, можно выделить отдельные классы для разных типов модификаторов, класс для хранения базового значения стата и класс-контроллер, который координирует весь процесс вычислений. Этот подход сложнее в первоначальной реализации, но значительно упрощает дальнейшую поддержку и расширение системы.
Рассмотрим такой пример: у нас есть монолитный класс Stat , который содержит списки для трех типов модификаторов (плоских, аддитивных и мультипликативных), методы для их добавления, удаления и расчета итогового значения. Если мы захотим добавить новый тип модификатора, нам придется изменить сам класс Stat , добавив новый список и новые методы, а также изменить логику расчета. Это нарушает принцип открытости-закрытости — один из фундаментальных принципов SOLID.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| public class Stat
{
private float _baseValue;
private List<Modifier> _flatModifiers = new();
private List<Modifier> _additiveModifiers = new();
private List<Modifier> _multiplicativeModifiers = new();
// Методы добавления, удаления и расчета...
private float CalculateValue()
{
float value = _baseValue;
// Расчет плоских модификаторов
// Расчет аддитивных модификаторов
// Расчет мультипликативных модификаторов
return value;
}
} |
|
Альтернативой может служить модульный подход с использованием интерфейсов и полиморфизма:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public interface IModifierOperation
{
void AddModifier(Modifier modifier);
bool RemoveModifier(Modifier modifier);
float CalculateModifierValue(float baseValue, float currentValue);
}
public class FlatModifierOperation : IModifierOperation
{
private List<Modifier> _modifiers = new();
public float CalculateModifierValue(float baseValue, float currentValue)
{
float result = 0;
foreach (var modifier in _modifiers)
result += modifier.Value;
return result;
}
// Реализация других методов...
} |
|
В этом случае Stat будет содержать коллекцию реализаций IModifierOperation , упорядоченных по приоритету вычислений. Добавление нового типа модификатора потребует лишь создания новой реализации интерфейса без изменения существующего кода.
Другой важный аспект — балансировка производительности и гибкости. В играх с большим количеством объектов, каждый из которых имеет множество статов (например, MMO с тысячами NPC), производительность становится критическим фактором. В таких случаях может быть оправдан более прямолинейный и оптимизированный подход, даже если он менее гибок. В то же время для синглплеерных RPG или стратегий, где количество активных объектов ограничено, а разнообразие и сложность статов высоки, приоритет смещается в сторону гибкости и расширяемости. Хорошим компромиссом может быть использование системы кэширования, когда сложные вычисления выполняются только при изменении соответствующих модификаторов, а не каждый кадр.
Компонентно-ориентированный подход, лежащий в основе Unity, хорошо сочетается с модульной архитектурой системы статов. Можно создать компонент StatComponent , который будет содержать коллекцию статов и предоставлять API для других компонентов. Такая организация позволяет легко добавлять статы к любым игровым объектам и создавать компоненты, которые взаимодействуют со статами унифицированным образом.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| public class StatComponent : MonoBehaviour
{
private Dictionary<StatType, Stat> _stats = new();
public float GetStatValue(StatType type)
{
if (_stats.TryGetValue(type, out Stat stat))
return stat.Value;
return 0;
}
// Другие методы...
}
public class DamageDealer : MonoBehaviour
{
[SerializeField] private StatType _damageStatType;
public float GetDamage()
{
var statComponent = GetComponent<StatComponent>();
return statComponent.GetStatValue(_damageStatType);
}
} |
|
Для хранения данных о статах часто используются ScriptableObjects, которые предлагают удобный способ определения общих настроек вне игровых объектов. Например, можно создать ScriptableObject `StatDefinition`, который определяет базовое значение стата, его границы, формулу расчета и другие параметры. Это упрощает балансировку игры, так как дизайнеры могут изменять эти определения без вмешательства в код.
C# | 1
2
3
4
5
6
7
8
9
10
| [CreateAssetMenu(fileName = "New Stat Definition", menuName = "Stats/Stat Definition")]
public class StatDefinition : ScriptableObject
{
public string StatName;
public float DefaultValue;
public float MinValue;
public float MaxValue;
public StatCalculationType CalculationType;
// Другие параметры...
} |
|
Альтернативный подход — сериализация данных в JSON или XML, что может быть удобно для игр с процедурной генерацией контента или для проектов, требующих частого обновления балансировки. Несмотря на большую гибкость, этот метод сложнее в реализации и может создавать проблемы с типобезопасностью.
Разные жанры игр требуют своих подходов к реализации статов. Для боевых игр и шутеров характерны простые, прямолинейные расчеты с упором на производительность. RPG требуют сложных формул с множеством факторов и взаимозависимостей. Стратегии часто используют многоуровневые системы, где статы отдельных юнитов влияют на характеристики армий или цивилизаций. Важно также учитывать аспекты локализации и представления статов игрокам. Имена статов, их описания и даже формат отображения значений (абсолютные числа или проценты) должны быть легко настраиваемыми. Хорошей практикой является разделение логики статов и их визуального представления, что позволяет гибко адаптировать интерфейс под нужды различных платформ и аудиторий.
Интересным подходом является использование системы событий для оповещения об изменениях статов. Это позволяет различным системам (UI, игровая логика, системы эффектов) реагировать на изменения без жесткой связанности:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| public class Stat
{
public event Action<float> ValueChanged;
private float _value;
public float Value
{
get => _value;
private set
{
if (_value != value)
{
_value = value;
ValueChanged?.Invoke(_value);
}
}
}
// Остальная реализация...
} |
|
Ключевым принципом проектирования остается соблюдение баланса между абстракцией и конкретикой. Слишком абстрактная система может оказаться излишне сложной и затратной в разработке, а слишком конкретная — негибкой. Оптимальный вариант обычно лежит посередине и определяется конкретными требованиями проекта, его масштабом и жанровыми особенностями.
Для реализации статов через интерфейсы и абстракции ключевую роль играет принцип подстановки Лисков, который гласит, что объекты в программе должны быть заменяемы их наследниками без изменения корректности программы. Реализуя этот принцип, можно создать гибкую систему модификаторов, где каждый тип будет иметь собственную реализацию расчета, но общий интерфейс взаимодействия. Начнем с определения базового интерфейса для операций с модификаторами:
C# | 1
2
3
4
5
6
7
| public interface IModifiersOperations
{
void AddModifier(Modifier modifier);
bool TryRemoveModifier(Modifier modifier);
List<Modifier> GetAllModifiers();
float CalculateModifiersValue(float baseValue, float currentValue);
} |
|
Этот интерфейс определяет четыре ключевые операции: добавление модификатора, удаление модификатора, получение списка всех модификаторов и расчет значения модификатора на основе базового и текущего значений стата. Такой подход позволяет легко создавать новые типы модификаторов, не меняя существующий код.
Дальше имеет смысл создать базовый абстрактный класс, который реализует общую функциональность для всех типов модификаторов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public abstract class ModifierOperationsBase : IModifiersOperations
{
protected readonly List<Modifier> Modifiers;
protected ModifierOperationsBase(int capacity) => Modifiers = new List<Modifier>(capacity);
protected ModifierOperationsBase() => Modifiers = new List<Modifier>(4);
public virtual void AddModifier(Modifier modifier)
{
CheckListCapacity(Modifiers, modifier.Type);
Modifiers.Add(modifier);
}
public virtual bool TryRemoveModifier(Modifier modifier) => Modifiers.Remove(modifier);
public virtual List<Modifier> GetAllModifiers() => Modifiers;
public abstract float CalculateModifiersValue(float baseValue, float currentValue);
// Вспомогательные методы...
} |
|
Обратите внимание на метод CalculateModifiersValue , который объявлен как абстрактный. Это означает, что каждый конкретный тип модификатора должен предоставить собственную реализацию расчета. Такой подход обеспечивает высокую гибкость системы. Теперь можно создать конкретные реализации для различных типов модификаторов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| public sealed class FlatModifierOperations : ModifierOperationsBase
{
public FlatModifierOperations(int capacity) : base(capacity) { }
public override float CalculateModifiersValue(float baseValue, float currentValue)
{
float flatModifiersSum = 0f;
for (var i = 0; i < Modifiers.Count; i++)
flatModifiersSum += Modifiers[i];
return flatModifiersSum;
}
}
public sealed class AdditiveModifierOperations : ModifierOperationsBase
{
public AdditiveModifierOperations(int capacity) : base(capacity) { }
public override float CalculateModifiersValue(float baseValue, float currentValue)
{
float additiveModifiersSum = 0f;
for (var i = 0; i < Modifiers.Count; i++)
additiveModifiersSum += Modifiers[i];
return baseValue * additiveModifiersSum;
}
}
public sealed class MultiplicativeModifierOperations : ModifierOperationsBase
{
public MultiplicativeModifierOperations(int capacity) : base(capacity) { }
public override float CalculateModifiersValue(float baseValue, float currentValue)
{
float calculatedValue = currentValue;
for (var i = 0; i < Modifiers.Count; i++)
calculatedValue += calculatedValue * Modifiers[i];
return calculatedValue - currentValue;
}
} |
|
Каждая реализация предоставляет свою логику расчета значения модификатора. Плоские модификаторы просто суммируются, аддитивные процентные умножаются на базовое значение, а мультипликативные применяются последовательно к текущему значению.
Для управления коллекцией модификаторов и их порядком применения можно создать специальный класс, который будет отвечать за создание и хранение операций модификаторов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
| internal sealed class ModifierOperationsCollection
{
private readonly Dictionary<ModifierType, Func<IModifiersOperations>> _modifierOperationsDict = new();
private bool _modifiersCollectionHasBeenReturned;
internal ModifierType AddModifierOperation(int order, Func<IModifiersOperations> modifierOperationsDelegate)
{
if (_modifiersCollectionHasBeenReturned)
throw new InvalidOperationException("Cannot change collection after it has been returned");
var modifierType = (ModifierType)order;
if (modifierType is ModifierType.Flat or ModifierType.Additive or ModifierType.Multiplicative)
Debug.LogWarning("Modifier operations for types flat, additive and multiplicative cannot be changed!");
_modifierOperationsDict[modifierType] = modifierOperationsDelegate;
return modifierType;
}
internal Dictionary<ModifierType, Func<IModifiersOperations>> GetModifierOperations(int capacity)
{
_modifierOperationsDict[ModifierType.Flat] = () => new FlatModifierOperations(capacity);
_modifierOperationsDict[ModifierType.Additive] = () => new AdditiveModifierOperations(capacity);
_modifierOperationsDict[ModifierType.Multiplicative] = () => new MultiplicativeModifierOperations(capacity);
_modifiersCollectionHasBeenReturned = true;
return _modifierOperationsDict;
}
} |
|
Этот класс хранит словарь делегатов, которые могут создавать операции для различных типов модификаторов. Он также гарантирует, что базовые типы модификаторов (плоские, аддитивные, мультипликативные) всегда доступны и не могут быть перезаписаны. Важная деталь здесь — флаг _modifiersCollectionHasBeenReturned , который предотвращает изменение коллекции после того, как она была возвращена и использована.
Теперь можно обновить и сам класс Stat , чтобы он использовал эту архитектуру:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
| [Serializable]
public sealed class Stat
{
private const int DEFAULT_LIST_CAPACITY = 4;
private const int DEFAULT_DIGIT_ACCURACY = 2;
internal const int MAXIMUM_ROUND_DIGITS = 8;
[SerializeField] private float baseValue;
private static ModifierOperationsCollection _ModifierOperationsCollection = new();
private readonly int _digitAccuracy;
private readonly List<Modifier> _modifiersList = new();
private readonly SortedList<ModifierType, IModifiersOperations> _modifiersOperations = new();
private float _currentValue;
private bool _isDirty;
public Stat(float baseValue, int digitAccuracy, int modsMaxCapacity)
{
this.baseValue = baseValue;
_currentValue = baseValue;
_digitAccuracy = digitAccuracy;
InitializeModifierOperations(modsMaxCapacity);
void InitializeModifierOperations(int capacity)
{
var modifierOperations = _ModifierOperationsCollection.GetModifierOperations(capacity);
foreach (var operationType in modifierOperations.Keys)
_modifiersOperations[operationType] = modifierOperations[operationType]();
}
}
// Конструкторы и свойства...
public void AddModifier(Modifier modifier)
{
IsDirty = true;
_modifiersOperations[modifier.Type].AddModifier(modifier);
}
public static ModifierType NewModifierType(int order, Func<IModifiersOperations> modifierOperationsDelegate)
{
try
{
return _ModifierOperationsCollection.AddModifierOperation(order, modifierOperationsDelegate);
}
catch
{
throw new InvalidOperationException("Add any modifier operations before any initialization of the Stat class!");
}
}
// Другие методы...
private float CalculateModifiedValue(int digitAccuracy)
{
digitAccuracy = Math.Clamp(digitAccuracy, 0, MAXIMUM_ROUND_DIGITS);
float finalValue = baseValue;
for (int i = 0; i < _modifiersOperations.Count; i++)
finalValue += _modifiersOperations.Values[i].CalculateModifiersValue(baseValue, finalValue);
IsDirty = false;
return (float)Math.Round(finalValue, digitAccuracy);
}
} |
|
Ключевое отличие этой реализации от монолитной заключается в том, что добавление нового типа модификатора не требует изменения класса Stat . Вместо этого создается новая реализация IModifiersOperations и регистрируется через статический метод NewModifierType .
Важным аспектом локализации и представления статов является разделение логики расчета и визуального отображения. Для этого можно использовать паттерн Model-View-Presenter (MVP) или Model-View-ViewModel (MVVM), где модель представлена системой статов, а представление — UI-элементами. Для локализации названий и описаний статов целесообразно использовать ScriptableObject с таблицами локализации или интеграцию с существующими системами локализации Unity, такими как Localization Package. При этом важно хранить только ключи для локализации в определениях статов, а не сами тексты.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| [Serializable]
public class StatDefinition
{
public string StatKey; // Ключ для локализации
public float BaseValue;
public StatFormat DisplayFormat; // Формат отображения (абсолютный, процентный)
// Другие параметры...
}
public enum StatFormat
{
Absolute,
Percentage,
Integer,
Time
} |
|
Для удобства дизайнеров и балансировщиков игры полезно создать редактор статов с визуальным представлением зависимостей между различными характеристиками. Это можно реализовать через кастомные редакторы Unity, используя классы Editor и EditorWindow .
Еще один важный аспект архитектуры системы статов — обработка событий изменения статов. Игровые системы должны иметь возможность реагировать на изменения статов без необходимости постоянного опроса их значений. Система событий может быть реализована через делегаты C# или через паттерн Observer:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class StatChangeEvent
{
public StatType Type { get; }
public float OldValue { get; }
public float NewValue { get; }
public StatChangeEvent(StatType type, float oldValue, float newValue)
{
Type = type;
OldValue = oldValue;
NewValue = newValue;
}
}
public interface IStatEventListener
{
void OnStatChanged(StatChangeEvent e);
} |
|
При проектировании архитектуры следует учитывать и сетевые особенности игры, если она многопользовательская. В таких случаях может потребоваться синхронизация статов между клиентами и сервером, что добавляет дополнительную сложность. Обычно в таких системах сервер является источником истины для значений статов, а клиенты могут иметь локальные копии для визуализации.
Практическая реализация
После определения архитектуры системы статов переходим к её фактической реализации. Фундаментом системы статов является грамотно спроектированный класс Modifier , который представляет собой изменение какой-либо характеристики. Реализуем его как структуру, что позволит снизить нагрузку на сборщик мусора и улучшить производительность за счёт локальности данных:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public readonly struct Modifier
{
public ModifierType Type { get; }
public object Source { get; }
private readonly float _value;
public Modifier(float value, ModifierType modifierType, object source = null)
{
_value = value;
Type = modifierType;
Source = source;
}
public static implicit operator float(Modifier modifier) => modifier._value;
} |
|
Заметьте, что мы реализовали неявное приведение типа к float , что упростит доступ к значению модификатора в расчётах. Кроме того, структура хранит ссылку на источник модификатора, что позволяет удалять все модификаторы от конкретного источника, например, при снятии предмета.
Перейдём к реализации класса Stat , который представляет конкретную характеристику:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
| [Serializable]
public sealed class Stat
{
[SerializeField] private float baseValue;
private readonly int _digitAccuracy;
private readonly SortedList<ModifierType, IModifiersOperations> _modifiersOperations;
private float _currentValue;
private bool _isDirty;
public event Action ValueChanged;
public event Action ModifiersChanged;
public Stat(float baseValue, int digitAccuracy = 2, int capacity = 4)
{
this.baseValue = baseValue;
_currentValue = baseValue;
_digitAccuracy = digitAccuracy;
_modifiersOperations = new SortedList<ModifierType, IModifiersOperations>();
InitializeModifierOperations(capacity);
}
public float Value
{
get
{
if (IsDirty)
{
_currentValue = CalculateModifiedValue(_digitAccuracy);
OnValueChanged();
}
return _currentValue;
}
}
// Остальная реализация...
} |
|
Обратите внимание на флаг _isDirty – он используется для оптимизации вычислений. Значение пересчитывается только при добавлении или удалении модификаторов, что значительно снижает нагрузку на CPU, особенно в играх с большим количеством объектов.
Для организации системы модификаторов используем паттерн "Цепочка обязанностей" (Chain of Responsibility), где каждый тип модификатора обрабатывается последовательно:
C# | 1
2
3
4
5
6
7
8
9
10
11
| private float CalculateModifiedValue(int digitAccuracy)
{
float finalValue = baseValue;
foreach (var operation in _modifiersOperations.Values)
finalValue += operation.CalculateModifiersValue(baseValue, finalValue);
IsDirty = false;
return (float)Math.Round(finalValue, digitAccuracy);
} |
|
Для реализации паттерна Observer, который обеспечивает уведомление всех заинтересованных систем об изменениях статов, используем систему событий C#:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| private bool IsDirty
{
get => _isDirty;
set
{
_isDirty = value;
if (_isDirty)
OnModifiersChanged();
}
}
private void OnValueChanged() => ValueChanged?.Invoke();
private void OnModifiersChanged() => ModifiersChanged?.Invoke(); |
|
Любой компонент может подписаться на эти события и реагировать на изменения статов, например обновляя UI или триггеря игровую логику:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public class HealthBar : MonoBehaviour
{
[SerializeField] private Image fillImage;
[SerializeField] private StatComponent statComponent;
[SerializeField] private StatType healthStatType;
private void Start()
{
var healthStat = statComponent.GetStat(healthStatType);
healthStat.ValueChanged += UpdateHealthBar;
UpdateHealthBar(); // Инициализация
}
private void UpdateHealthBar()
{
var healthStat = statComponent.GetStat(healthStatType);
fillImage.fillAmount = healthStat.Value / healthStat.MaxValue;
}
private void OnDestroy()
{
var healthStat = statComponent.GetStat(healthStatType);
healthStat.ValueChanged -= UpdateHealthBar;
}
} |
|
При работе с зависимыми статами важно избегать циклических зависимостей и обеспечивать корректный порядок пересчёта. Один из подходов — использование системы первичных и вторичных статов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public class DerivedStat : Stat
{
private readonly List<Stat> _dependentStats = new();
private readonly Func<float> _calculateBaseValue;
public DerivedStat(Func<float> baseValueCalculator, int digitAccuracy = 2, int capacity = 4)
: base(0, digitAccuracy, capacity)
{
_calculateBaseValue = baseValueCalculator;
}
public void AddDependency(Stat stat)
{
_dependentStats.Add(stat);
stat.ValueChanged += MarkDirty;
MarkDirty();
}
public void RemoveDependency(Stat stat)
{
if (_dependentStats.Remove(stat))
{
stat.ValueChanged -= MarkDirty;
MarkDirty();
}
}
public override float BaseValue
{
get => _calculateBaseValue();
}
private void MarkDirty() => IsDirty = true;
protected override void OnDispose()
{
foreach (var stat in _dependentStats)
stat.ValueChanged -= MarkDirty;
base.OnDispose();
}
} |
|
Такой подход позволяет создавать характеристики, которые зависят от других статов. Например, здоровье может зависеть от выносливости:
C# | 1
2
3
| var strength = new Stat(10);
var health = new DerivedStat(() => 50 + strength.Value * 5);
health.AddDependency(strength); |
|
Для эффективного кэширования расчётов можно использовать не только флаг IsDirty , но и более сложные методы, такие как версионирование:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| public class VersionedStat : Stat
{
private int _version = 0;
private readonly Dictionary<int, float> _cachedValues = new();
public float GetValueForLevel(int level)
{
if (_cachedValues.TryGetValue(level, out float value) && !IsDirty)
return value;
value = CalculateValueForLevel(level);
_cachedValues[level] = value;
return value;
}
public void InvalidateCache()
{
_version++;
_cachedValues.Clear();
IsDirty = true;
}
protected virtual float CalculateValueForLevel(int level)
{
return BaseValue * (1 + 0.1f * level);
}
} |
|
При работе со статами необходимо предусмотреть защиту от крайних случаев — отрицательных значений, переполнения или непредвиденных результатов расчётов. Для этого можно добавить проверки и ограничения:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class ClampedStat : Stat
{
private readonly float _minValue;
private readonly float _maxValue;
public ClampedStat(float baseValue, float minValue, float maxValue, int digitAccuracy = 2, int capacity = 4)
: base(baseValue, digitAccuracy, capacity)
{
_minValue = minValue;
_maxValue = maxValue;
}
public override float Value
{
get => Mathf.Clamp(base.Value, _minValue, _maxValue);
}
} |
|
Для обработки особых случаев можно использовать систему перехватчиков (interceptors), которые модифицируют результат стандартных расчётов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| public class InterceptableStat : Stat
{
private readonly List<Func<float, float>> _valueInterceptors = new();
public void AddInterceptor(Func<float, float> interceptor)
{
_valueInterceptors.Add(interceptor);
IsDirty = true;
}
public void RemoveInterceptor(Func<float, float> interceptor)
{
if (_valueInterceptors.Remove(interceptor))
IsDirty = true;
}
public override float Value
{
get
{
float value = base.Value;
foreach (var interceptor in _valueInterceptors)
value = interceptor(value);
return value;
}
}
} |
|
Такой подход позволяет реализовать сложную логику обработки статов, например, временную неуязвимость или критические эффекты:
C# | 1
2
3
4
5
6
7
8
9
10
| var damageStat = new InterceptableStat(10);
// Добавление перехватчика для критического урона
bool isCritical = Random.value < 0.2f;
if (isCritical)
damageStat.AddInterceptor(damage => damage * 2);
// Временная неуязвимость
character.AddInterceptor(damage => 0);
StartCoroutine(RemoveInvulnerabilityAfterDelay(character, 2f)); |
|
Для полноты понимания рассмотрим практический пример расширения системы статов новым типом модификатора. Допустим, нам требуется создать модификатор "Абсолютное подавление", который будет игнорировать все прочие модификаторы и устанавливать значение стата как процент от его базового значения. При этом мы хотим, чтобы из нескольких таких модификаторов применялся только тот, который даёт наибольшее подавление.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class BaseAbsoluteReductionModifierOperations : ModifierOperationsBase
{
public BaseAbsoluteReductionModifierOperations(int capacity) : base(capacity) { }
public override float CalculateModifiersValue(float baseValue, float currentValue)
{
var biggestModifier = 0f;
for (var i = 0; i < Modifiers.Count; i++)
biggestModifier = Mathf.Max(biggestModifier, Modifiers[i]);
var modifierValue = biggestModifier == 0f ? 0f : baseValue * (1 - biggestModifier) - currentValue;
return modifierValue;
}
} |
|
После создания класса операций для нового типа модификатора, необходимо зарегистрировать его в системе. Для этого используем статический метод NewModifierType , который мы предусмотрели в классе Stat :
C# | 1
| var BaseAbsoluteReduction = Stat.NewModifierType(400, () => new BaseAbsoluteReductionModifierOperations()); |
|
Значение 400 указывает порядок применения модификатора — в данном случае он будет применяться после всех стандартных типов (плоских, аддитивных и мультипликативных), что соответствует логике его работы. Теперь мы можем создавать модификаторы нового типа и применять их к статам:
C# | 1
2
3
| var strengthStat = new Stat(100);
var strengthCurse = new Modifier(0.2f, BaseAbsoluteReduction, source: curseItem);
strengthStat.AddModifier(strengthCurse); |
|
После добавления этого модификатора, значение стата будет составлять 80 (100 * (1 - 0.2)), независимо от других модификаторов. Когда проклятие будет снято, стат вернётся к значению, рассчитанному с учётом других действующих модификаторов.
Для организации множества статов в игровых объектах удобно использовать компонентный подход:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
| public class StatController : MonoBehaviour
{
[Serializable]
private class StatDefinition
{
public StatType Type;
public float BaseValue;
public int Precision = 2;
public int Capacity = 4;
}
[SerializeField] private StatDefinition[] statDefinitions;
private Dictionary<StatType, Stat> _stats = new();
private void Awake()
{
foreach (var definition in statDefinitions)
{
_stats[definition.Type] = new Stat(definition.BaseValue, definition.Precision, definition.Capacity);
}
}
public Stat GetStat(StatType type)
{
if (_stats.TryGetValue(type, out var stat))
return stat;
Debug.LogWarning($"Stat of type {type} not found on {gameObject.name}");
return null;
}
public bool TryStat(StatType type, out Stat stat)
{
return _stats.TryGetValue(type, out stat);
}
} |
|
Этот компонент позволяет настраивать набор статов прямо в инспекторе Unity и предоставляет API для доступа к ним из других скриптов. Для улучшения пользовательского опыта можно создать кастомный редактор, который будет отображать текущие значения статов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| [CustomEditor(typeof(StatController))]
public class StatControllerEditor : Editor
{
public override void OnInspectorGUI()
{
base.OnInspectorGUI();
if (Application.isPlaying)
{
EditorGUILayout.Space();
EditorGUILayout.LabelField("Current Values", EditorStyles.boldLabel);
var controller = (StatController)target;
var stats = new List<StatType>(Enum.GetValues(typeof(StatType)) as StatType[]);
foreach (var type in stats)
{
if (controller.TryStat(type, out var stat))
{
EditorGUILayout.LabelField($"{type}: {stat.Value}");
}
}
}
}
} |
|
Для более сложных игровых механик может потребоваться система вторичных статов, значения которых зависят от первичных. Например, в RPG здоровье может зависеть от выносливости, а мана — от интеллекта. Реализуем это с помощью специального класса:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
| public class DerivedStatController : MonoBehaviour
{
[Serializable]
private class DerivedStatDefinition
{
public StatType Type;
public StatType[] DependsOn;
public string Formula;
public int Precision = 2;
public int Capacity = 4;
}
[SerializeField] private DerivedStatDefinition[] derivedStats;
[SerializeField] private StatController statController;
private Dictionary<StatType, DerivedStat> _derivedStats = new();
private Dictionary<string, Func<float[], float>> _formulas = new Dictionary<string, Func<float[], float>>()
{
{"sum", values => values.Sum()},
{"product", values => values.Aggregate(1f, (a, b) => a * b)},
{"max", values => values.Max()},
{"min", values => values.Min()},
{"health", values => 50 + values[0] * 5}, // Предполагается, что первый стат - выносливость
{"mana", values => 30 + values[0] * 3}, // Предполагается, что первый стат - интеллект
};
private void Start()
{
foreach (var definition in derivedStats)
{
var dependentStats = new List<Stat>();
foreach (var dependency in definition.DependsOn)
{
dependentStats.Add(statController.GetStat(dependency));
}
var formula = _formulas[definition.Formula];
var derivedStat = new DerivedStat(() =>
{
var values = dependentStats.Select(s => s.Value).ToArray();
return formula(values);
}, definition.Precision, definition.Capacity);
foreach (var stat in dependentStats)
{
derivedStat.AddDependency(stat);
}
_derivedStats[definition.Type] = derivedStat;
}
}
public Stat GetStat(StatType type)
{
if (_derivedStats.TryGetValue(type, out var stat))
return stat;
return null;
}
} |
|
Этот контроллер создаёт вторичные статы на основе формул и зависимостей, определённых в инспекторе. Формулы представлены как функции, которые принимают массив значений первичных статов и возвращают значение вторичного стата. Такой подход позволяет гибко настраивать взаимосвязи между статами без изменения кода.
Расширенная функциональность
Создав основную систему статов, можно расширить её дополнительной функциональностью, которая сделает игровую механику более разнообразной и интересной. Начнём с реализации временных эффектов — аспекта, который присутствует практически в любой игре с развитой системой характеристик. Временные эффекты (баффы и дебаффы) добавляют динамику игровому процессу и создают дополнительные тактические возможности. Для реализации временных эффектов требуется создать систему, которая будет отслеживать длительность действия модификаторов и автоматически их удалять по истечении срока. Простой подход — создание компонента, управляющего временными модификаторами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| public class TemporaryEffectManager : MonoBehaviour
{
private class TemporaryEffect
{
public Stat TargetStat;
public Modifier AppliedModifier;
public float RemainingDuration;
public Action OnExpired;
public TemporaryEffect(Stat targetStat, Modifier modifier, float duration, Action onExpired = null)
{
TargetStat = targetStat;
AppliedModifier = modifier;
RemainingDuration = duration;
OnExpired = onExpired;
}
}
private List<TemporaryEffect> _activeEffects = new();
public void ApplyTemporaryEffect(Stat targetStat, Modifier modifier, float duration, Action onExpired = null)
{
targetStat.AddModifier(modifier);
_activeEffects.Add(new TemporaryEffect(targetStat, modifier, duration, onExpired));
}
private void Update()
{
for (int i = _activeEffects.Count - 1; i >= 0; i--)
{
var effect = _activeEffects[i];
effect.RemainingDuration -= Time.deltaTime;
if (effect.RemainingDuration <= 0)
{
effect.TargetStat.TryRemoveModifier(effect.AppliedModifier);
effect.OnExpired?.Invoke();
_activeEffects.RemoveAt(i);
}
}
}
} |
|
Этот менеджер позволяет применять временные эффекты к любым статам с указанной продолжительностью и опциональным колбэком по истечении срока. Использование такой системы может выглядеть так:
C# | 1
2
3
| var strengthBuff = new Modifier(10, ModifierType.Flat, source: potion);
effectManager.ApplyTemporaryEffect(character.GetStat(StatType.Strength), strengthBuff, 30f,
() => characterFeedback.ShowMessage("Действие зелья силы закончилось")); |
|
Для более сложных игр может потребоваться система сопротивляемости к эффектам. Сопротивляемость может уменьшать силу эффекта или его продолжительность, а в некоторых случаях полностью предотвращать его применение. Реализуем это как расширение предыдущего компонента:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public void ApplyTemporaryEffectWithResistance(Stat targetStat, Modifier baseModifier, float baseDuration,
float resistanceValue, Action onExpired = null)
{
if (Random.value < resistanceValue)
{
// Эффект полностью сопротивлен
return;
}
// Уменьшаем силу эффекта пропорционально сопротивляемости
float modifiedValue = baseModifier.Value * (1 - resistanceValue * 0.5f);
var modifiedModifier = new Modifier(modifiedValue, baseModifier.Type, baseModifier.Source);
// Уменьшаем длительность пропорционально сопротивляемости
float modifiedDuration = baseDuration * (1 - resistanceValue * 0.3f);
ApplyTemporaryEffect(targetStat, modifiedModifier, modifiedDuration, onExpired);
} |
|
Дебаффы и особые состояния часто требуют более сложной логики, чем просто изменение статов. Например, эффект оглушения может полностью запрещать персонажу выполнять действия. Для таких случаев удобно создать систему состояний, которая будет работать параллельно с системой статов:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| public enum StatusEffectType
{
Stun,
Silence,
Root,
Invisible,
Invulnerable
}
public class StatusEffectManager : MonoBehaviour
{
private Dictionary<StatusEffectType, float> _activeStatusEffects = new();
public bool HasStatusEffect(StatusEffectType type) => _activeStatusEffects.ContainsKey(type);
public void ApplyStatusEffect(StatusEffectType type, float duration)
{
if (_activeStatusEffects.TryGetValue(type, out float currentDuration))
{
_activeStatusEffects[type] = Mathf.Max(currentDuration, duration);
}
else
{
_activeStatusEffects[type] = duration;
OnStatusEffectApplied(type);
}
}
private void Update()
{
var keys = new List<StatusEffectType>(_activeStatusEffects.Keys);
foreach (var key in keys)
{
_activeStatusEffects[key] -= Time.deltaTime;
if (_activeStatusEffects[key] <= 0)
{
_activeStatusEffects.Remove(key);
OnStatusEffectRemoved(key);
}
}
}
protected virtual void OnStatusEffectApplied(StatusEffectType type) { }
protected virtual void OnStatusEffectRemoved(StatusEffectType type) { }
} |
|
Важным аспектом любой игровой системы является сохранение и загрузка состояния. Для сериализации статов можно создать специальные классы-контейнеры, которые будут хранить только необходимую информацию:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
| [Serializable]
public class StatSaveData
{
public StatType Type;
public float BaseValue;
public List<ModifierSaveData> Modifiers = new();
}
[Serializable]
public class ModifierSaveData
{
public float Value;
public ModifierType Type;
public string SourceId; // Идентификатор источника для восстановления ссылки
}
public class StatSaveSystem
{
public List<StatSaveData> SaveStats(Dictionary<StatType, Stat> stats)
{
var saveData = new List<StatSaveData>();
foreach (var pair in stats)
{
var statData = new StatSaveData
{
Type = pair.Key,
BaseValue = pair.Value.BaseValue
};
foreach (var modifier in pair.Value.GetModifiers())
{
if (modifier.Source is IIdentifiable identifiable)
{
statData.Modifiers.Add(new ModifierSaveData
{
Value = modifier,
Type = modifier.Type,
SourceId = identifiable.Id
});
}
}
saveData.Add(statData);
}
return saveData;
}
public void LoadStats(List<StatSaveData> saveData, Dictionary<StatType, Stat> stats,
Func<string, object> sourceResolver)
{
foreach (var statData in saveData)
{
if (stats.TryGetValue(statData.Type, out var stat))
{
stat.BaseValue = statData.BaseValue;
foreach (var modData in statData.Modifiers)
{
var source = sourceResolver(modData.SourceId);
var modifier = new Modifier(modData.Value, modData.Type, source);
stat.AddModifier(modifier);
}
}
}
}
} |
|
Визуализация и обратная связь при изменении статов играют важную роль в создании понятного и отзывчивого UI. Можно создать компонент, который будет отслеживать изменения статов и отображать соответствующие эффекты:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| public class StatVisualFeedback : MonoBehaviour
{
[SerializeField] private StatController statController;
[SerializeField] private GameObject buffEffectPrefab;
[SerializeField] private GameObject debuffEffectPrefab;
private Dictionary<StatType, float> _previousValues = new();
private void Start()
{
foreach (StatType type in Enum.GetValues(typeof(StatType)))
{
if (statController.TryStat(type, out Stat stat))
{
_previousValues[type] = stat.Value;
stat.ValueChanged += () => OnStatChanged(type, stat);
}
}
}
private void OnStatChanged(StatType type, Stat stat)
{
float previousValue = _previousValues[type];
float newValue = stat.Value;
if (newValue > previousValue)
{
ShowBuffEffect(type, newValue - previousValue);
}
else if (newValue < previousValue)
{
ShowDebuffEffect(type, previousValue - newValue);
}
_previousValues[type] = newValue;
}
private void ShowBuffEffect(StatType type, float amount)
{
var effect = Instantiate(buffEffectPrefab, transform.position + Vector3.up, Quaternion.identity);
effect.GetComponent<FloatingText>().SetText($"+{amount} {type}");
}
private void ShowDebuffEffect(StatType type, float amount)
{
var effect = Instantiate(debuffEffectPrefab, transform.position + Vector3.up, Quaternion.identity);
effect.GetComponent<FloatingText>().SetText($"-{amount} {type}");
}
} |
|
Система пороговых значений и триггеров позволяет создавать игровые механики, которые активируются при достижении статом определённых значений. Например, персонаж может получить дополнительные эффекты, когда его здоровье опускается ниже 30%:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| public class StatThresholdTrigger : MonoBehaviour
{
[Serializable]
public class Threshold
{
public float Value;
public bool IsPercentage;
public UnityEvent OnCrossedDown;
public UnityEvent OnCrossedUp;
[HideInInspector] public bool IsActive;
}
[SerializeField] private StatController statController;
[SerializeField] private StatType monitoredStatType;
[SerializeField] private List<Threshold> thresholds = new();
private Stat _monitoredStat;
private void Start()
{
_monitoredStat = statController.GetStat(monitoredStatType);
_monitoredStat.ValueChanged += CheckThresholds;
CheckThresholds(); // Проверка начальных значений
}
private void CheckThresholds()
{
float currentValue = _monitoredStat.Value;
float maxValue = _monitoredStat is ClampedStat clampedStat ? clampedStat.MaxValue : float.MaxValue;
foreach (var threshold in thresholds)
{
float thresholdValue = threshold.IsPercentage ? maxValue * threshold.Value / 100 : threshold.Value;
if (currentValue <= thresholdValue && !threshold.IsActive)
{
threshold.IsActive = true;
threshold.OnCrossedDown?.Invoke();
}
else if (currentValue > thresholdValue && threshold.IsActive)
{
threshold.IsActive = false;
threshold.OnCrossedUp?.Invoke();
}
}
}
} |
|
Для создания интуитивного редактора настройки системы статов через кастомные инспекторы Unity можно использовать возможности редакторного API. Такой редактор значительно упрощает работу с системой статов для дизайнеров, не требуя от них знания кода:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
| [CustomEditor(typeof(StatDefinitionCollection))]
public class StatDefinitionEditor : Editor
{
private SerializedProperty _statDefinitions;
private int _selectedDefinitionIndex = -1;
private bool _showAdvancedOptions = false;
private void OnEnable()
{
_statDefinitions = serializedObject.FindProperty("statDefinitions");
}
public override void OnInspectorGUI()
{
serializedObject.Update();
EditorGUILayout.BeginHorizontal();
EditorGUILayout.LabelField("Доступные статы", EditorStyles.boldLabel);
if (GUILayout.Button("Добавить стат", GUILayout.Width(100)))
{
_statDefinitions.arraySize++;
_selectedDefinitionIndex = _statDefinitions.arraySize - 1;
}
EditorGUILayout.EndHorizontal();
EditorGUILayout.Space();
// Отображение списка статов
for (int i = 0; i < _statDefinitions.arraySize; i++)
{
SerializedProperty statDef = _statDefinitions.GetArrayElementAtIndex(i);
SerializedProperty nameProperty = statDef.FindPropertyRelative("name");
EditorGUILayout.BeginHorizontal("box");
if (GUILayout.Button(i == _selectedDefinitionIndex ? "▼" : "►", GUILayout.Width(20)))
_selectedDefinitionIndex = i == _selectedDefinitionIndex ? -1 : i;
EditorGUILayout.PropertyField(nameProperty, GUIContent.none);
if (GUILayout.Button("×", GUILayout.Width(20)))
{
_statDefinitions.DeleteArrayElementAtIndex(i);
serializedObject.ApplyModifiedProperties();
return;
}
EditorGUILayout.EndHorizontal();
// Отображение детальных настроек выбранного стата
if (i == _selectedDefinitionIndex)
{
EditorGUI.indentLevel++;
SerializedProperty baseValueProperty = statDef.FindPropertyRelative("baseValue");
SerializedProperty minValueProperty = statDef.FindPropertyRelative("minValue");
SerializedProperty maxValueProperty = statDef.FindPropertyRelative("maxValue");
SerializedProperty formatProperty = statDef.FindPropertyRelative("displayFormat");
EditorGUILayout.PropertyField(baseValueProperty, new GUIContent("Базовое значение"));
EditorGUILayout.PropertyField(minValueProperty, new GUIContent("Минимум"));
EditorGUILayout.PropertyField(maxValueProperty, new GUIContent("Максимум"));
EditorGUILayout.PropertyField(formatProperty, new GUIContent("Формат отображения"));
_showAdvancedOptions = EditorGUILayout.Foldout(_showAdvancedOptions, "Расширенные настройки");
if (_showAdvancedOptions)
{
SerializedProperty modifiersProperty = statDef.FindPropertyRelative("allowedModifiers");
EditorGUILayout.PropertyField(modifiersProperty, new GUIContent("Доступные модификаторы"), true);
SerializedProperty dependenciesProperty = statDef.FindPropertyRelative("dependencies");
EditorGUILayout.PropertyField(dependenciesProperty, new GUIContent("Зависимости"), true);
}
EditorGUI.indentLevel--;
}
}
serializedObject.ApplyModifiedProperties();
}
} |
|
Для визуализации более сложных взаимосвязей между статами может быть полезно создать отдельное окно редактора с графическим представлением:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
| public class StatDependencyGraph : EditorWindow
{
private StatDefinitionCollection _statCollection;
private Dictionary<string, Rect> _nodePositions = new Dictionary<string, Rect>();
private Vector2 _offset;
private Vector2 _dragStartPos;
[MenuItem("Tools/Stat System/Dependency Graph")]
public static void ShowWindow()
{
GetWindow<StatDependencyGraph>("Граф зависимостей статов");
}
private void OnGUI()
{
if (_statCollection == null)
{
EditorGUILayout.HelpBox("Выберите коллекцию статов для отображения", MessageType.Info);
if (GUILayout.Button("Загрузить коллекцию"))
{
_statCollection = EditorGUIUtility.Load("StatDefinitions") as StatDefinitionCollection;
InitializeGraph();
}
return;
}
// Обработка событий перетаскивания
HandleDragEvents();
// Отрисовка связей между статами
DrawConnections();
// Отрисовка узлов графа
DrawNodes();
}
private void InitializeGraph()
{
// Инициализация позиций узлов
float x = 50, y = 50;
foreach (var statDef in _statCollection.statDefinitions)
{
_nodePositions[statDef.name] = new Rect(x, y, 120, 50);
x += 150;
if (x > position.width - 150)
{
x = 50;
y += 100;
}
}
}
private void DrawNodes()
{
GUIStyle nodeStyle = new GUIStyle(GUI.skin.window);
BeginWindows();
for (int i = 0; i < _statCollection.statDefinitions.Length; i++)
{
var statDef = _statCollection.statDefinitions[i];
if (_nodePositions.TryGetValue(statDef.name, out Rect nodeRect))
{
_nodePositions[statDef.name] = GUI.Window(i, nodeRect, WindowFunction, statDef.name, nodeStyle);
}
}
EndWindows();
}
private void WindowFunction(int id)
{
var statDef = _statCollection.statDefinitions[id];
GUILayout.Label($"База: {statDef.baseValue}");
GUILayout.Label($"Мин/Макс: {statDef.minValue}/{statDef.maxValue}");
GUI.DragWindow();
}
private void DrawConnections()
{
Handles.BeginGUI();
foreach (var statDef in _statCollection.statDefinitions)
{
if (statDef.dependencies != null && statDef.dependencies.Length > 0)
{
if (_nodePositions.TryGetValue(statDef.name, out Rect targetRect))
{
Vector3 targetPos = new Vector3(targetRect.x + targetRect.width / 2, targetRect.y + targetRect.height / 2, 0);
foreach (var dependency in statDef.dependencies)
{
if (_nodePositions.TryGetValue(dependency, out Rect sourceRect))
{
Vector3 sourcePos = new Vector3(sourceRect.x + sourceRect.width / 2, sourceRect.y + sourceRect.height / 2, 0);
Handles.DrawBezier(
sourcePos, targetPos,
sourcePos + Vector3.down * 50, targetPos + Vector3.up * 50,
Color.gray, null, 2f);
}
}
}
}
}
Handles.EndGUI();
}
private void HandleDragEvents()
{
if (Event.current.type == EventType.MouseDown && Event.current.button == 1)
{
_dragStartPos = Event.current.mousePosition;
_offset = Vector2.zero;
Event.current.Use();
}
else if (Event.current.type == EventType.MouseDrag && Event.current.button == 1)
{
_offset = Event.current.mousePosition - _dragStartPos;
foreach (var key in _nodePositions.Keys.ToList())
{
var rect = _nodePositions[key];
rect.position += _offset;
_nodePositions[key] = rect;
}
_dragStartPos = Event.current.mousePosition;
Repaint();
Event.current.Use();
}
}
} |
|
Процедурная генерация предметов с балансированными статами — ещё одна важная функция игровых систем, особенно для RPG и roguelike игр. Правильно настроенная система должна создавать предметы соответствующей игровому прогрессу ценности, и при этом достаточно разнообразные:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
| public class ItemGenerator : MonoBehaviour
{
[Serializable]
public class StatRangeByLevel
{
public AnimationCurve ValueMinCurve = AnimationCurve.Linear(0, 1, 100, 100);
public AnimationCurve ValueMaxCurve = AnimationCurve.Linear(0, 5, 100, 500);
public float RarityMultiplier = 1.5f;
}
[SerializeField] private Dictionary<StatType, StatRangeByLevel> _statRanges = new();
[SerializeField] private List<ModifierType> _availableModifierTypes = new();
[SerializeField] private AnimationCurve _modifierCountByRarityCurve = AnimationCurve.Linear(0, 1, 5, 5);
public Item GenerateItem(ItemType type, int playerLevel, float rarity)
{
// Ограничиваем редкость в диапазоне [0, 1]
rarity = Mathf.Clamp01(rarity);
// Определяем количество модификаторов на основе редкости
int modifierCount = Mathf.RoundToInt(_modifierCountByRarityCurve.Evaluate(rarity));
// Создаем основу предмета
Item item = new Item
{
Type = type,
Level = Mathf.Max(1, playerLevel - 5 + Mathf.RoundToInt(rarity * 10)),
Rarity = rarity,
Name = GenerateItemName(type, rarity)
};
// Список возможных статов для данного типа предмета
List<StatType> availableStats = GetAvailableStatsForItemType(type);
// Случайный выбор статов для данного предмета (без повторений)
List<StatType> selectedStats = SelectRandomStats(availableStats, modifierCount);
// Генерация значений для выбранных статов
foreach (var statType in selectedStats)
{
if (_statRanges.TryGetValue(statType, out var statRange))
{
// Определяем диапазон значений в зависимости от уровня
float minValue = statRange.ValueMinCurve.Evaluate(item.Level);
float maxValue = statRange.ValueMaxCurve.Evaluate(item.Level);
// Применяем множитель редкости
float rarityMultiplier = 1 + (rarity * (statRange.RarityMultiplier - 1));
maxValue *= rarityMultiplier;
// Генерируем значение стата
float statValue = UnityEngine.Random.Range(minValue, maxValue);
// Выбираем тип модификатора (плоский, аддитивный, мультипликативный и т.д.)
ModifierType modifierType = SelectModifierTypeForStat(statType);
// Добавляем модификатор к предмету
item.StatModifiers.Add(new ItemStatModifier
{
Type = statType,
ModifierType = modifierType,
Value = statValue
});
}
}
return item;
}
private string GenerateItemName(ItemType type, float rarity)
{
// Генерация имени в зависимости от типа и редкости предмета
// Здесь может быть сложная логика с прилагательными и суффиксами
string prefix = GetRarityPrefix(rarity);
string baseName = GetItemBaseName(type);
string suffix = rarity > 0.7f ? GetMagicalSuffix() : "";
return string.Join(" ", new[] { prefix, baseName, suffix }.Where(s => !string.IsNullOrEmpty(s)));
}
private List<StatType> GetAvailableStatsForItemType(ItemType type)
{
// Каждый тип предмета может иметь свой набор возможных статов
switch (type)
{
case ItemType.Weapon:
return new List<StatType> { StatType.Damage, StatType.AttackSpeed, StatType.CritChance };
case ItemType.Armor:
return new List<StatType> { StatType.Defense, StatType.Health, StatType.ElementalResistance };
case ItemType.Accessory:
return new List<StatType> { StatType.MagicPower, StatType.CooldownReduction, StatType.MovementSpeed };
default:
return new List<StatType>();
}
}
private List<StatType> SelectRandomStats(List<StatType> availableStats, int count)
{
// Выбираем случайные статы без повторений
count = Mathf.Min(count, availableStats.Count);
List<StatType> result = new List<StatType>();
List<StatType> pool = new List<StatType>(availableStats);
for (int i = 0; i < count; i++)
{
int index = UnityEngine.Random.Range(0, pool.Count);
result.Add(pool[index]);
pool.RemoveAt(index);
}
return result;
}
private ModifierType SelectModifierTypeForStat(StatType statType)
{
// Некоторые статы лучше работают с определенными типами модификаторов
// Например, для здоровья обычно используется плоский модификатор,
// а для шанса крита - аддитивный
switch (statType)
{
case StatType.Health:
case StatType.Damage:
return ModifierType.Flat;
case StatType.CritChance:
case StatType.ElementalResistance:
return ModifierType.Additive;
case StatType.AttackSpeed:
case StatType.MovementSpeed:
return ModifierType.Multiplicative;
default:
// Случайный выбор из доступных типов
return _availableModifierTypes[UnityEngine.Random.Range(0, _availableModifierTypes.Count)];
}
}
private string GetRarityPrefix(float rarity)
{
if (rarity < 0.2f) return "Обычный";
if (rarity < 0.4f) return "Необычный";
if (rarity < 0.6f) return "Редкий";
if (rarity < 0.8f) return "Эпический";
return "Легендарный";
}
private string GetItemBaseName(ItemType type)
{
// Базовые имена для различных типов предметов
// В реальном проекте здесь могут быть словари или более сложная логика
switch (type)
{
case ItemType.Weapon:
string[] weaponNames = { "Меч", "Топор", "Молот", "Кинжал", "Посох" };
return weaponNames[UnityEngine.Random.Range(0, weaponNames.Length)];
case ItemType.Armor:
string[] armorNames = { "Нагрудник", "Шлем", "Поножи", "Перчатки", "Сапоги" };
return armorNames[UnityEngine.Random.Range(0, armorNames.Length)];
case ItemType.Accessory:
string[] accessoryNames = { "Амулет", "Кольцо", "Браслет", "Пояс", "Кулон" };
return accessoryNames[UnityEngine.Random.Range(0, accessoryNames.Length)];
default:
return "Предмет";
}
}
private string GetMagicalSuffix()
{
string[] suffixes = {
"Мощи", "Возмездия", "Жизненной силы", "Быстроты",
"Мудрости", "Проницательности", "Превосходства", "Ярости"
};
return "of " + suffixes[UnityEngine.Random.Range(0, suffixes.Length)];
}
} |
|
Этот генератор предметов обеспечивает балансировку через кривые анимации, позволяющие дизайнерам точно настраивать распределение статов в зависимости от уровня персонажа и редкости предмета. Генератор также учитывает тип предмета при выборе доступных статов, что создаёт предметы, соответствующие контексту игры.
Оптимизация и интеграция
Правильно спроектированная система статов должна не только корректно выполнять расчёты, но и делать это эффективно, с минимальной нагрузкой на процессор. Особенно это критично для многопользовательских проектов, где количество сущностей с большим числом статов может достигать тысяч на одной сцене. Рассмотрим основные стратегии оптимизации, которые помогут избежать проблем с производительностью. Ключевая оптимизация, которую мы уже затронули ранее — ленивые вычисления, реализованные через флаг isDirty . Однако можно пойти дальше и реализовать более продвинутые техники кэширования:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
| public class CachingStat : Stat
{
private struct CacheEntry
{
public float Value;
public int ComputationId;
}
private int _currentComputationId = 0;
private Dictionary<string, CacheEntry> _computationCache = new();
public float GetComputedValueForContext(string cacheKey, Func<float, float> contextComputation)
{
// Если есть валидный кэш для этого контекста — используем его
if (_computationCache.TryGetValue(cacheKey, out var entry) &&
entry.ComputationId == _currentComputationId)
return entry.Value;
// Иначе вычисляем и сохраняем
float computedValue = contextComputation(Value);
_computationCache[cacheKey] = new CacheEntry
{
Value = computedValue,
ComputationId = _currentComputationId
};
return computedValue;
}
public override void InvalidateCache()
{
base.InvalidateCache();
_currentComputationId++;
// Не обязательно чистить словарь - записи с устаревшим ID будут перезаписаны
}
} |
|
Этот подход позволяет кэшировать результаты вычислений для разных игровых контекстов. Например, значение силы персонажа может по-разному влиять на урон разных типов оружия. Вместо пересчёта для каждого оружия при каждом запросе, мы кэшируем результат для каждого контекста.
При проектировании системы статов для многопользовательских игр крайне важно определить, какие расчёты выполняются на сервере, а какие на клиенте. Золотое правило — все критичные для геймплея вычисления должны проводиться на сервере. Клиент может кэшировать эти значения и даже выполнять промежуточные расчёты для отзывчивости интерфейса, но финальная валидация должна происходить на стороне сервера.
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| // Серверная часть
public class ServerStatController : MonoBehaviour
{
private Dictionary<int, Dictionary<StatType, Stat>> _playerStats = new();
public float GetPlayerStatValue(int playerId, StatType statType)
{
if (_playerStats.TryGetValue(playerId, out var stats) &&
stats.TryGetValue(statType, out var stat))
return stat.Value;
return 0;
}
public void ValidateClientModifier(int playerId, StatType statType,
Modifier modifier, Action<bool> callback)
{
// Проверка на читерство или другие недопустимые модификаторы
bool isValid = ValidateModifier(playerId, statType, modifier);
if (isValid)
{
_playerStats[playerId][statType].AddModifier(modifier);
// Отправка обновления всем клиентам...
}
callback(isValid);
}
private bool ValidateModifier(int playerId, StatType statType, Modifier modifier)
{
// Логика проверки легитимности модификатора
return true; // Упрощённый пример
}
} |
|
Еще один важный аспект оптимизации — объемы данных, передаваемых по сети. Вместо отправки полных данных о статах и модификаторах, используйте дельта-сжатие — передавайте только изменения:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
| [Serializable]
public struct StatDelta
{
public int EntityId;
public StatType Type;
public float NewBaseValue;
public List<ModifierDelta> ModifierChanges;
}
[Serializable]
public struct ModifierDelta
{
public ModifierDeltaType ChangeType; // Добавлен, удалён, изменён
public ModifierType Type;
public float Value;
public string SourceId;
}
// Применение дельты на клиенте
public void ApplyStatDelta(StatDelta delta)
{
var entity = EntityManager.GetEntity(delta.EntityId);
if (entity == null) return;
var statComponent = entity.GetComponent<StatComponent>();
var stat = statComponent.GetStat(delta.Type);
// Обновление базового значения если изменилось
if (Math.Abs(stat.BaseValue - delta.NewBaseValue) > float.Epsilon)
stat.BaseValue = delta.NewBaseValue;
// Применение изменений модификаторов
foreach (var modDelta in delta.ModifierChanges)
{
switch (modDelta.ChangeType)
{
case ModifierDeltaType.Added:
object source = null;
if (!string.IsNullOrEmpty(modDelta.SourceId))
source = SourceRegistry.GetSource(modDelta.SourceId);
stat.AddModifier(new Modifier(modDelta.Value, modDelta.Type, source));
break;
case ModifierDeltaType.Removed:
// Удаление по значению и типу - упрощённо
foreach (var mod in stat.GetModifiers(modDelta.Type))
{
if (Math.Abs((float)mod - modDelta.Value) < float.Epsilon)
{
stat.TryRemoveModifier(mod);
break;
}
}
break;
}
}
} |
|
При интеграции системы статов с другими системами игры главный принцип — слабая связанность (loose coupling). Системы должны взаимодействовать через чётко определённые интерфейсы, а не напрямую манипулировать данными друг друга.
Для этого отлично подходит паттерн Посредник (Mediator), который централизует взаимодействие между различными компонентами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| public class GameSystems : MonoBehaviour
{
[SerializeField] private StatSystem statSystem;
[SerializeField] private InventorySystem inventorySystem;
[SerializeField] private CombatSystem combatSystem;
[SerializeField] private QuestSystem questSystem;
private void Start()
{
// Подписка систем на события друг друга
inventorySystem.ItemEquipped += OnItemEquipped;
inventorySystem.ItemUnequipped += OnItemUnequipped;
combatSystem.DamageDealt += OnDamageDealt;
statSystem.StatThresholdCrossed += OnStatThresholdCrossed;
}
private void OnItemEquipped(Item item, Character character)
{
// Применение модификаторов предмета к статам персонажа
statSystem.ApplyItemModifiers(item, character);
// Проверка условий квестов
questSystem.CheckItemEquipQuests(item, character);
}
private void OnItemUnequipped(Item item, Character character)
{
// Удаление модификаторов предмета из статов персонажа
statSystem.RemoveItemModifiers(item, character);
}
private void OnDamageDealt(Character attacker, Character target, float amount)
{
// Обновление статистики боя
statSystem.UpdateCombatStats(attacker, target, amount);
// Проверка условий квестов
questSystem.CheckCombatQuests(attacker, target, amount);
}
private void OnStatThresholdCrossed(Character character, StatType type,
float oldValue, float newValue, float threshold)
{
if (type == StatType.Health && newValue <= 0)
{
// Обработка смерти персонажа
combatSystem.HandleCharacterDeath(character);
}
// Проверка условий квестов
questSystem.CheckStatThresholdQuests(character, type, newValue, threshold);
}
} |
|
При росте проекта система статов должна масштабироваться без потери производительности и поддерживаемости кодовой базы. Один из ключевых подходов — модульность. Разбейте систему на независимые компоненты, каждый с чётко определённой ответственностью. Например, отдельный модуль для вычисления значений статов, модуль для сериализации/десериализации и модуль для визуализации.
Архитектурный подход на основе событий (Event-Driven Architecture) может значительно снизить связанность между компонентами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
| public static class StatEvents
{
public static event Action<int, StatType, float> StatValueChanged;
public static event Action<int, StatType, Modifier> ModifierAdded;
public static event Action<int, StatType, Modifier> ModifierRemoved;
public static void RaiseStatValueChanged(int entityId, StatType type, float newValue)
{
StatValueChanged?.Invoke(entityId, type, newValue);
}
// Другие методы вызова событий...
} |
|
Такая система позволяет добавлять новые компоненты без изменения существующих. Например, можно добавить систему достижений, которая будет отслеживать изменения статов, просто подписавшись на соответствующие события.
При профилировании системы статов стоит обратить внимание на несколько распространённых узких мест:
1. Частое выделение памяти — особенно при создании временных коллекций или при Boxing/Unboxing операциях с типами-значениями. Используйте пулинг объектов и структуры вместо классов где это возможно.
2. Избыточные пересчёты — используйте дополнительные флаги, отслеживающие изменения конкретных типов модификаторов, чтобы пересчитывать только нужные значения:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| public class OptimizedStat : Stat
{
private bool _flatModifiersChanged;
private bool _additiveModifiersChanged;
private bool _multiplicativeModifiersChanged;
// Кэшированные промежуточные результаты
private float _flatModifiersSum;
private float _additiveEffect;
protected override float CalculateModifiedValue()
{
if (_flatModifiersChanged)
{
_flatModifiersSum = CalculateFlatModifiersSum();
_flatModifiersChanged = false;
}
if (_additiveModifiersChanged)
{
_additiveEffect = CalculateAdditiveEffect();
_additiveModifiersChanged = false;
}
// И так далее...
return BaseValue + _flatModifiersSum + _additiveEffect + CalculateMultiplicativeEffect();
}
} |
|
3. Излишняя синхронизация в многопоточной среде — выделите чисто функциональные части системы, не требующие синхронизации, в отдельные методы, которые можно безопасно вызывать из разных потоков.
Для выявления конкретных проблем используйте профайлеры Unity, особенно Memory Profiler для анализа выделений памяти и CPU Profiler для выявления долгих вычислений. Обращайте внимание на глубокие иерархии вызовов и рекурсивные зависимости между статами.
В больших проектах эффективно использовать системы сборки метрик производительности, которые будут автоматически выявлять регрессии при изменении кода:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public class StatPerformanceMetrics : MonoBehaviour
{
[SerializeField] private StatController statController;
[SerializeField] private int iterationCount = 1000;
[Button] // Используя расширение для Editor
public void MeasureAddModifierPerformance()
{
var stat = statController.GetStat(StatType.Strength);
var sw = Stopwatch.StartNew();
for (int i = 0; i < iterationCount; i++)
{
var modifier = new Modifier(1, ModifierType.Flat);
stat.AddModifier(modifier);
stat.TryRemoveModifier(modifier);
}
sw.Stop();
Debug.Log($"Модификаторы: {iterationCount} итераций за {sw.ElapsedMilliseconds}мс");
}
} |
|
Для эффективного тестирования системы статов используйте модульные тесты, проверяющие различные сценарии:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| [Test]
public void StatValueIsCorrectlyCalculatedWithMultipleModifiers()
{
// Arrange
var stat = new Stat(100);
stat.AddModifier(new Modifier(10, ModifierType.Flat));
stat.AddModifier(new Modifier(0.2f, ModifierType.Additive));
stat.AddModifier(new Modifier(0.1f, ModifierType.Multiplicative));
// Act
float value = stat.Value;
// Assert
Assert.AreEqual(143, value, 0.01f);
// Ожидаемое значение: 100 (база) + 10 (плоский) + 0.2*100 (аддитивный) = 130
// Затем 130 + 130*0.1 (мультипликативный) = 143
} |
|
Для интеграционного тестирования создайте специальные тестовые сцены, которые проверят взаимодействие системы статов с другими системами:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| [UnityTest]
public IEnumerator EquippingItemCorrectlyAffectsCharacterStats()
{
// Arrange
var character = GameObject.Instantiate(characterPrefab);
var inventory = character.GetComponent<InventorySystem>();
var statController = character.GetComponent<StatController>();
var weapon = CreateTestWeapon(15, 0.1f);
var initialStrength = statController.GetStat(StatType.Strength).Value;
// Act
inventory.EquipItem(weapon);
yield return null; // Ждем кадр для обработки событий
// Assert
var newStrength = statController.GetStat(StatType.Strength).Value;
Assert.AreEqual(initialStrength + 15, newStrength);
} |
|
Не забывайте о нагрузочном тестировании — особенно важном для многопользовательских игр, где система должна обрабатывать большое количество сущностей одновременно. Создайте сценарии с сотнями или тысячами сущностей, имеющих статы, и измерьте производительность при частых изменениях.
Для облегчения отладки добавьте подробное логирование, которое можно включать в режиме разработки:
C# | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| public class DebuggableStat : Stat
{
[SerializeField] private bool enableDetailedLogging;
public override void AddModifier(Modifier modifier)
{
base.AddModifier(modifier);
if (enableDetailedLogging)
Debug.Log($"[STAT] {Name} модификатор добавлен: {modifier}, новое значение: {Value}");
}
public void PrintModifiersBreakdown()
{
var sb = new StringBuilder();
sb.AppendLine($"===== Информация о стате {Name} =====");
sb.AppendLine($"Базовое значение: {BaseValue}");
// Вывод детальной информации по категориям модификаторов
// ...
sb.AppendLine($"Итоговое значение: {Value}");
Debug.Log(sb.ToString());
}
} |
|
Проблема в Unity all compiler errors have to be fixed unity Всем доброго времени суток,столкнулся с такой проблемой в юнити
Проект 2d
Для кода использую... Есть тут кто пишет на C# для Unity? Под игры созданные в Unity читы делаются? Привет. Есть тут кто пишет на C# для Unity?
Под игры созданные в Unity читы делаются?
Такое... Unity 2d unity.engine.ui не работает using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using... Разработка игр с Unity без Unity редактора Здравствуйте.
Хочу обрисовать ситуацию.
Я слепой. Полностью слеп.
Среди незрячих программистов... Как реализовать свайп вправо-вверх или влево-вниз и т.д. в 3D проекте Unity (Двойной свайп в Unity)? Здравствуйте. Я не могу решить одну большую проблему
(3D проект на платформе Windows)
Суть:... Unity ECS ( нет блин CS:S) - Mr. Unity, мы ждём документацию Всё до боли просто, да и дело не горит, но хочется знать всего одну вещь: как получить архитектуру... Как работать с Unity Analytics в Unity 2020+? Приветствую, делал по туториалу: 3jDD-E1OUkc. На 7:03 он выбирает Analytics Event Tracker. Хотя на... Unity, Разработка игр на Unity за 24 часа Читаю книгу "Разработка игр на Unity за 24 часа" остановился на часе 6, где надо сделать игру... Unity Cloud(Unity сервисы) Доброго времени суток, такая проблема: реализовал сохранение и загрузку данных через unity сервисы,... Глючит система анимации Unity В анимации предка изменяю положение потомка. Включён режим записи. Изменяю положение ползунка и... Система частиц в Unity Как сделать в Unity чтобы частицы появлялись и потом создавали заданный спрайт?
Вот пример как... Unity 2D - система ближнего боя в платформере Доброго времени суток всем участникам форума!
Недавно начал разрабатывать платформер, в нем я хочу...
|