Страницы

Поиск по вопросам

суббота, 30 ноября 2019 г.

Модификаторы атрибутов в играх

#c_sharp #net


Подскажите, принцип (архитектуру, структуру), как организовать объекты, свойства
которых могут зависеть от разных факторов?

Для примера возьмем ХитПоинты и броню

public class Player
{
  decimal _hitPoints;
  decimal _armor;

  public decimal HitPoints
  {
    get
    {
      return _hitPoints;
    }
    set
    {
      _hitPoints = value;
    }
  }

  public decimal Armor
  {
    get
    {
      return _armor;
    }
    set
    {
      _armor = value;
    }
  }
}


Итак, у игрока есть хитпоинты. Их количество может меняться посредством каких либо
действий или состояний. 

Пример действий:


игрок может сожрать булочку
игрок может упасть
игрок может покончить жизнь самоубийством
игрока может атаковать другой игрок


Пример состояния:


у игрока есть постоянная регенерация
на игрока повесили бафы / дебафы


Все это может повлиять на количество хитпоинтов. 

Менять состояние в лоб

public DecraseHitpoints(decimal value)
{
  HitPoints -= value * [функция от брони] * [функция от состояния1] * [функция от
состояния2];
}


проблематично, потому что добавив новую зависимость я замучаюсь все это поддерживать.
Функции от состояния в данном случае - что угодно, например, баф - снижение получаемого
урона на 50%.

Короче обычные привычные подходы тут не подходят :)

Нужна кардинально другая архитектура.

Я представляю это себе примерно так. Класс имеет коллекцию действий и состояний.
Действие должно быть направлено на изменение какого то свойства (или нескольких свойств)
и описывать КАК эти свойства должны быть изменены. Опять же должна быть зависимость
от состояния. Например, если в данный момент игрок неуязвим, урон вообще не должен
проходить. 

Голова кругом идет уже.

UPD:

Ну да, при этом действие, нацеленное на изменение свойств, должно не просто учесть
состояния, которые могут менять воздействие, но оно не должно знать обо всех состояниях,
а только о воздействии состояния. То есть не нужно знать обо всех возможных бафах,
нужно знать только о том, что на игрока наложены бафы, которые могут изменить зависимые
величины (броню, количество урона непосредственно)...
    


Ответы

Ответ 1



Можно подглядеть как это сделано у других. Например, в Skyrim это делается через систему перков. К каждому персонажу привязывается динамический список перков, которые могут иметь разные точки входа. Точка входа - это игровое событие (например, получение урона). Перки обрабатывают событие в некотором порядке - и могут менять обрабатываемое значение (в случае получения урона - величину отнимаемого здоровья). У перка могут быть условия срабатывания (день/ночь, источник урона, надетые предметы и пр.). Полностью копировать такую систему не стоит - просто потому что она была сделана для гейм-дизайнеров, у вас же отдельного гейм-дизайнера нет и вы можете вместо создания мегауниверсального движка просто писать код. Выглядеть это может таким образом: class BaseModifier { public virtual void ModifyIncomingDamage(ref decimal value, DamageEvent e) { } public virtual void ЧтоТоЕще(ref decimal value, КакойТоЕщеEvent e) { } } class ArmorModifier : BaseModifier { public override void ModifyIncomingDamage(ref decimal value, DamageEvent e) { value *= функцияОтБрони; } } class ModifierCollection { private readonly List modifiers = new List(); public void Add(BaseModifier m) => modifiers.Add(m); public void Remove(BaseModifier m) where T : BaseModifier => modifiers.Remove(m); public void Remove() where T : BaseModifier => Remove(modifiers.OfType().First()); public decimal ModifyIncomingDamage(decimal value, DamageEvent e) { foreach (var m in modifiers) m.ModifyIncomingDamage(ref value, e); return value; } } class Character { public decimal HitPoints { get; set; } public ModifierCollection Modifiers { get; } = new ModifierCollection(); public void Hit(decimal value, DamageEvent e) => HitPoints -= Modifiers.ModifyIncomingDamage(value, e); } Теперь про упомянутую вами регенерацию здоровья. Сама по себе регенерация - это не состояние, это величина. Точнее, это величина, определяемая "навешанными" на персонажа эффектами. Хранить ее можно двумя путями. Не хранить вообще, а при необходимости пробегаться по всем активным эффектам и суммировать их воздействия. Хранить как число, изменять при появлении/исчезновении эффекта. Второй вариант предпочтительнее (будет работать быстрее) - но требует большей аккуратности при реализации. Непосредственно реализация регенерации может быть достигнута двумя способами. По таймеру - периодически обновлять здоровье всех персонажей. Непрерывный вариант - обновлять здоровье при чтении или изменении состояния, в зависимости от прошедшего с момента прошлого обновления времени. Недостаток первого варианта - в том, что при слишком большом интервале обновления регенерация станет заметно рваной, а слишком низкий интервал нагрузит процессор впустую. Недостаток второго варианта - надо писать много кода. Плюс может быть проблема с умениями, которые делают текущую регенерацию здоровья зависимой от текущего значения здоровья (пример такой вредной способности - пассивка мурлока в "Жизни на арене"). Поэтому обычно не заморачиваются и обновляют здоровье по таймеру. А чтобы полоска здоровья не дергалась прыжками - в интерфейсе делаются плавные переходы. В итоге, регенерация здоровья может быть реализована примерно так: class Character { public decimal HitPoints { get; set; } public decimal HitPointsRegen { get; set; } public ModifierCollection Modifiers { get; } = new ModifierCollection(); public void OnUpdate(decimal interval) { HitPoints += Modifiers.ModifyRegen(HitPointsRegen, this) * interval; } } Можно также запоминать модифицированное значение регенерации здоровья для ускорения: class Character { public decimal HitPoints { get; set; } private decimnal _hitPointsRegen, _hitPointsRegenModified; public decimal HitPointsRegen { get { return _hitPointsRegen; } set { _hitPointsRegen = value; _hitPointsRegenModified = Modifiers.ModifyRegen(HitPointsRegen, this); } } public ModifierCollection Modifiers { get; } = new ModifierCollection (); public Character() { Modifiers.ModifiersUpdated += OnModifiersUpdated; } protected void OnModifiersUpdated() { HitPointsRegen = HitPointsRegen; // Обновление зависимых значений } public void OnUpdate(decimal interval) { HitPoints += _hitPointsRegenModified * interval; } }

Ответ 2



Обычно логику изменения свойств размещают непосредственно в классе, поскольку именно это и есть инкапсуляция. Однако, если эта логика становится очень сложной, или изменяется чаще, чем остальной класс, имеет смысл вынести её в отдельный класс или даже набор классов. Важное замечание номер один: программисты любят придумывать и реализовывать универсальные решения для всего на свете. Из-за этого их код очень сложно читать и поддерживать. Поэтому сложные решения надо применять в том случае, если у вас действительно появляется двенадцатое состояние за последние два месяца. Если вы предполагаете, что оно может появиться, это другое. Действуйте по факту, как это предлагает Agile. Эрик Эванс, автор DDD, называет такие классы Регламентами. Они хранят правила предметной области, в вашем случае — игровые правила. Простой класс-регламент получит на вход текущие показания хит-поинтов, текущее состояние, и вычислит новое значение. Вопрос, что делать со сложным случаями, когда появляются новые состояния и формы расчёта в каждом состоянии сильно различаются. Здесь можно применить паттерн Стратегия. В данном случае речь будет идти о стратегии расчёта хитпоинтов. Напомню, что Стратегия — это набор классов с одинаковым интерфейсом, в вашем случае чуть ли не с единственным методом CalculateHitpoints. Конкретный класс выбирается исходя из состояния, и затем вызывается метод расчёта, который и посчитает всё так, как принято для этого состояния. Например, как то так. _hitPoints = _reglament.GetStrategyByState(_state).CalculateHitPoints(_hitPoints); Я бы рекомендовал такой код, если состояний много и они взаимоисключающие. Если состояния всего два: под озверином, и не под озверином, то Стратегия — слишком сложный паттерн. Последний случай — состояния могут накладываться друг на друга и их действительно много, например, десяток или два. Первое, что вы должны сделать, это убить своего игрового дизайнера, но если это по каким-то причинам невозможно, переходите к запасному варианту — используйте паттерн Цепочка ответственностей. Здесь у нас будут несколько классов, которые передают результаты расчёта друг другу и вносят свои модификации в зависимости от своего назначения. Все классы однотипны, реализуют один и тот же интерфейс и являются по сути Декораторами. Если игрок входит в состояние «под озверином» (это очень условная иллюстрация), вы добавляете соответствующий класс в цепочку, и теперь модификация хитпоинтов будет происходить с учётом озверина. В конце цепочки находится класс с тем же интерфейсом, который никого не вызывает, а считает изменение хитпоинтов без учёта состояний. Резюме, по шагам: Вначале попробуйте не выносить логику расчётов из класса Player. Если логика слишком большая, сделайте класс-регламент, вынесите логику в него. Если класс всё равно получается огромным, то, в зависимости от специфики: Разбейте его на несколько Стратегий Разбейте его на звенья в Цепочке ответственностей

Ответ 3



Если у вас слишком сложная логика внутри одного свойства, то превратите это свойство в класс. Пусть у вас будет класс HitPoints со свойством CurrentValue. В этот класс можно положить логику регенерации (запуск таймера и увеличение CurrentValue), текущий временный бафф (храните базовое значение Value, текущий список баффов, а также запустите таймер для снятия баффов в нужный момент, а в CurrentValue держите базовое значение после снятия баффов). Этим вы вынесете часть логики наружу. Теперь, изменение хитпойнтов при еде/получении удара -- это свойство самого игрока, а не внутренняя логика хитпойнтов, потому что они могут быть разными у разных игроков. Но в принципе можно это внести и в класс HitPoints, если параметризировать снаружи. Вам нужны функции пересчёта количества/типа еды в добавленные хитпойнты, а также логика по пересчёту количества урона в зависимости от типа оружия/противника в убираемые хитпойнты. (Если эта логика слишком сложная, вынесите её тоже в отдельную сущность.) Вроде бы всё.

Ответ 4



Можно напилить обёртку, которая будет хранить модификаторы, а уж модификаторы сами будут работать с изменениями и прочей мутью. public abstract class Modificator { public abstract T Calculate(T original, T withModificators); } public class ModificatorContainer { public List> Modificators { get; } public T OriginalValue { get; set; } public T CalculatedValue { get { return Calculate(); } } private T Calculate() { return Modificators.Aggregate(OriginalValue, (current, modificator) => modificator.Calculate(OriginalValue, current)); } public ModificatorContainer(T original) { this.OriginalValue = original; this.Modificators = new List>(); } } public class Player { public ModificatorContainer HitPoints { get; set; } public ModificatorContainer Armor { get; set; } public Player() { this.Armor = new ModificatorContainer(0); this.HitPoints = new ModificatorContainer(100); } } Добавляем кастомный модификатор брони в процентах: public class ArmorInProcent : Modificator { public int Percents { get; set; } public override decimal Calculate(decimal original, decimal withModificators) { return withModificators * Percents / 100; } public ArmorInProcent(int percents) { Percents = percents; } } И его применяем: var pl = new Player(); pl.Armor.OriginalValue = 1; pl.Armor.Modificators.Add(new ArmorInProcent(120)); Накидано на коленке, но идея, думаю, понятна.

Ответ 5



Раскопал свои старые любительские наработки и таки нашел как я реализовывал подобные фишки с модификаторами. Что-то было где-то подсмотрено, что-то придумано с нуля, так что не претендую на оригинальность и идеальность решения. Игровой цикл разбивается на шаги, реалтайм реалтаймом, но надо когда-то и просчеты графики и искина противников делать. Я делал по аналогии с Minecraft - 1 тик = 1/20 с = 50 мс. Тики отсчитываются тупо таймером, все остальное привязано к этому таймеру. Ввод действий от пользователя, разумеется, фиксируется постоянно, но учитывается только в ближайшем за вводом тике. Этого времени в среднем хватает и на относительно плавную анимацию, и на обсчет действий врагов. Игрок и противники наследуются от общего базового класса, как вариант применяем декораторы, но я использовал тупо наследование (напоминаю. проект был любительский - для себя). Это избавляет от необходимости по разному работать с эффектами для игрока и врагов. В базовом классе определяем все общие игровые показатели, каждый показатель по сути отдельный объект в массиве параметров сущности. Для здоровья такой класс может выглядеть так: class UnitParameter { public string Name { get; private set; } //отличительное имя параметра public UnitParameter (string name, double value, double maxvalue) { Name = name; _current = value; _max = maxValue; } private double _current ; private double _max; public double GetCurrent { get { return Math.Round(_current, 0); } } public double GetMax { get { return Math.Round(_max, 0); } } //этот метод вызывается в конце каждого тика, после просчета всех остальных действий public void Update() { _max += MaxLvlMods.Sum(); _current += CurrentMods.Sum(); _current = Math.Min(_current, _max); } public List CurrentMods { get; } = new List(); public List MaxMods { get; } = new List(); } Почему double? чтобы не мучиться с эффектами вроде неуязвимости или бессмертия, и использовать для этого константу double.PositiveInfinity, равно как и с эффектами вроде перманентной смерти, когда воскрешение не возможно - double.NegativeInfinity. Теперь эффекты, для этого понадобится класс эффекта, например такой: class Effect { public string Name { get; private set; } //отличительное имя модификатора public string ParamName { get; private set; } //отличительное имя параметра public Effect (string name, string paramName, double value, double count) { Name = name; ParamName = paramName; _value = value; _tickCount = count; } private double _Value;//величина действия эффекта private double _tickCount; //длительность действия эффекта в тиках public double LeftTicks { get { return Math.Round(_tickCount, 0); } } public double Value { get { return Math.Round(_value, 0); } } public Update() { //тут может быть логика для сложных эффектов, вроде увеличения эффекта со временем или вызов делегата в котором эта логика описывается. _tickCount--; } } Отлично, у нас есть параметр и эффект, осталось применить эффект к параметру. Зададим параметры юнита словарем для удобства, а наложенные на него эффекты в обычный список. Dictionary params; List effects; Теперь для применения эффектов на игрока достаточно выполнить простой цикл effects.RemoveAll(e => e.LeftTicks < 0); //удаляем просроченные эффекты foreach (var effect in effects) { UnitParameter param; if (params.TryGetValue(effect.ParamName, out param) param.CurrentMods.Add(effect.Value) effect.Update(); } Урон от противника можно считать наложением эффекта потери здоровья с нулевой длительностью, т.е. одноразового. Алгоритм наложения эффектов на максимальные значения параметров не сильно изменит общую логику, поэтому оставляю для самостоятельных размышлений. И того мы получили вариант, при котором однотипные по сути параметры (здоровье, мана, броня и т.д.) и эффекты для них описываются двумя классами, не ограничены в количестве наложенных эффектов и самих параметров, довольно быстро работают. При необходимости особых эффектов и параметров, можно расширить за счет наследования или декораций. Только осторожнее с наложением эффекта абсолютной смерти double.NegativeInfinity на бессмертных double.PositiveInfinity, в сумме получается NaN =) но я думаю не сложно написать проверку на такой случай. Думаю на этом можно закончить, но если интересны какие-то еще подробности, пишите в комментарии, дополню.

Комментариев нет:

Отправить комментарий