Игры во многом построены на условиях. Если скорость больше нуля, надо сменить айдл анимацию на что-то другое. Если хп меньше нуля, персонаж считается мертвым. Если у персонажа 10 клыков, он может сдать квест. Если в руках лазер, надо зажать ЛКМ для непрерывной стрельбы. С ружьем обычно так не получается, здесь одно нажатие — один выстрел. Если в руках молоток, то всё превращается в гвозди.

Какие‑то из них оставляются как есть, какие‑то выносятся в отдельный именованный метод, для каких‑то используются абстракции, чтобы избежать бесконтрольного увеличения когнитивной нагрузки при расширении и поддержке.

И если задача на реализацию системы активных умений может восприниматься как что‑то, для чего может потребоваться гибкая система. Особенно если по диздоку ясно, что их будет много и они будут сложно устроены. То задача на «запускать катсцену при входе в уровень» так воспринимается не всегда, так как это может показаться разовой отдельной задачей.

Я бы в таком случае сделал бы словарь с ключом — именем уровня и значением — катсценой, объяснил бы геймдизайнерам, как с ним работать, довольный закры л задачу.

[Serializable]
public class CutsceneData
{
   public string LevelName;
   public string VideoPath;
}

public class CutsceneHandler
{
   private CutsceneData[] _cutscenes;
  
   public bool TryGetCustscene(string levelName, out CutsceneData cutscene)
   {
	   cutscene = null;
       foreach (var cutsceneData in _cutscenes)
       {
           if (cutsceneData.LevelName == levelName)
           {
               cutscene = cutsceneData;
               return true;
           }
       }


       cutscene = null;
       return false;
   }
}

Другая похожая задача может оказаться на туториал. Если игрок достиг 3 уровня, то у него разблокировалась новая механика, надо его ему обучить. Можно себе легко представить задачу на квесты/ачивки, которая тоже будет что-то требовать.



В какой-то момент станет очевидным, что условия в этих задачах пересекаются, при этом каждый конкретный случай требует доработки конкретной системы, а геймдизайнеру приходится заново объяснять, как работать с дополнением новой системы.

Я выделяю следующие задачи для системы условий, которые и попытаюсь решить в статье:

  1. Система должна подходить под разные задачи с минимальными изменениями/добавлениями

  2. Система должна подходить для работы геймдизайнерам

  3. Над ней можно работать в отрыве от юнити

Формат данных

Основа

Начну с простого. Предположим, что есть квест на сбор 10 шкур. Данные для него, конечно же, выглядят довольно очевидно

public class ItemConditionData
{
   public ItemType Id;
   public int Count;
}

Есть выдача ачивки за достижение игроком уровня

public class LevelConditionData
{
   public int Level;
}

Есть старт катсцены после пройденного уровня

public class LevelCompleteConditionData
{
   public string LevelName;
}

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

public class ConditionDataHolder
{
   /*Здесь я предполагаю, что будут встречаться условия,
     требующие несколько разных предметов в разных количествах*/ 
   public ItemConditionData[] ItemCondition;
   public LevelConditionData LevelCondition;
}

Но теперь, если надо объединить уровень и пройденную локацию, то я предлагаю не создавать для этой пары новый класс, а просто дописать сюда

public class ConditionDataHolder
{
   public ItemConditionData[] ItemCondition;
   public LevelConditionData LevelCondition;
   //Если уровни проходятся в порядке, выбираемом игроком, можно заменить на массив
   public LevelCompleteConditionData LevelCompleteCondition;
}

Собственно, объединение всех таких условий в одну сущность и является идеей статьи. Дальше будут подробности

Примитивная логика

Что делать, если надо указывать не точное число, а какой‑то интервал вида «больше пяти» или «все уровни после Замка» (при условии, что уровни идут по очереди)? Можно расширить готовый ItemConditionData полем со сравнением

public class ItemConditionData
{
   public ItemType Id;
   public int Count;
   public EqualType Equal;
}

public enum EqualType
{
   Equal,
   NotEqual,
   More,
   Less,
   MoreOrEqual,
   LessOrEqual
}

Можно, конечно, создавать на каждое сравнение свой класс, но как по мне, это сильно усложнит дату.

Эти примеры уже покрывают довольно большое количество игровых условий. Но что делать, если надо принести 10 шкур или 10 рогов? Или должно быть выполнено ровно одно условие из списка? Можно расширить дату небольшой логикой.

public class Any
{
   public ConditionDataHolder[] Conditions;
}

public class OneOf
{
   public ConditionDataHolder[] Conditions;
}

public class ConditionDataHolder
{
   public ItemConditionData[] ItemCondition;
   public LevelConditionData LevelCondition;
   public LevelCompleteConditionData LevelCompleteCondition;
   public AnyCondition Any;
   public OneOfCondition OneOf;
}

Обработка

Теперь, имея голые данные, хочется их как-то обработать. Здесь для простоты буду считать, что отдельного ассембли для этих данных нет, а потому нет проблем с обращением к основной части игры.

Инициализация

Сначала, для удобства, надо для условий добавить интерфейс для проверок. И записать их все в один список

internal interface ICondition
{
   bool IsAvailable();
}


public class ConditionDataHolder : ICondition
{
   public ItemConditionData[] ItemCondition;
   public LevelConditionData LevelCondition;
   public LevelCompleteConditionData LevelCompleteCondition;
   public AnyCondition Any;
   public OneOfCondition OneOf;

   public void Init()
   {
       void AddRange(ICondition[] conditions)
       {
           if (conditions != null)
           {
               _conditions.AddRange(conditions);
           }
       }
       _conditions = new List<ICondition>()
       {
           Any,
           OneOf,
           LevelCondition,
           LevelCompleteCondition
       };
      
       AddRange(ItemCondition);
   }
}


public class OneOfCondition : ICondition{...}
public class AnyCondition : ICondition{...}
public class ItemConditionData : ICondition{...}
public class LevelConditionData : ICondition{...}
public class LevelCompleteConditionData : ICondition {...}

После этого можно проверить каждое условие на доступность

public class ConditionDataHolder : ICondition
{
   //...
   public bool IsAvailable()
   {
       return _conditions.All(c => c == null || c.IsAvailable());
   }
}

Логические проверки будут выглядеть так

public class OneOfCondition : ICondition
{
   public ConditionDataHolder[] Conditions;
  
   public bool IsAvailable()
   {
       return Conditions.Any(c => c.IsAvailable());
   }
}

public class AnyCondition : ICondition
{
   public ConditionDataHolder[] Conditions;

   public bool IsAvailable()
   {
       return Conditions.Count(v => v.IsAvailable()) == 1;
   }
}

Глобальные проверки

Условия будут сами проверять стейт. Возьмем для примера максимально простой стейт игрока:

public class PlayerState
{
   public int PlayerLevel;
   public Dictionary<ItemType, int> Inventory;
   public List<string> CompletedLevelNames;
}

Тогда проверка уровня будет выглядеть так:

public class LevelConditionData : ICondition
{
   public int Level;
   private PlayerState _playerState;
  
   public bool IsAvailable()
   {
       return _playerState.PlayerLevel == Level;
   }
}

Для того, чтобы передать PlayerState внутрь условия, можно добавить инициализацию в интерфейс.

internal interface ICondition
{
   bool IsAvailable();
   void Init(PlayerState playerState);
}


public class ConditionDataHolder : ICondition
{
   //...
   public void Init(PlayerState playerState)
   {
       //...
       foreach (var condition in _conditions)
       {
           condition?.Init(playerState);
       }
   }
}

public class LevelConditionData : ICondition
{
   public int Level;
   private PlayerState _playerState;
  
   public bool IsAvailable()
   {
       return _playerState.PlayerLevel == Level;
   }

   public void Init(PlayerState playerState)
   {
       _playerState = playerState;
   }
}

Дополнительные параметры

Что делать, если не все данные для проверок находятся в глобальном стейте? На этот случай подойдет структура для локальных данных

public struct SpecialData
{
   public float TimeSinceQuestTaked;
   public int CurrentRunScorePoint;
   public int BuffCount;
}

internal interface ICondition
{
   bool IsAvailable(SpecialData specialData);
   void Init(PlayerState playerState);
}

Здесь можно сразу заметить, что эта дата может бесконтрольно разрастаться при множестве ситуативных данных. Если это ваш случай, а отказываться от предложенного мной подхода не хочется, то стоит подумать над какой-то прослойкой, которая будет собирать данные для конкретного условия

Где хранить?

Очевидные варианты

Мой опыт показывает, что самыми популярными форматами хранения данных являются встроенные в юнити Scriptable Object и обычный json. Для условий я бы рекомендовал второй вариант, так как в таком случае можно избежать стандартных для эдитора проблем. Пример произвольного условия в json.

{
 "QuestName": "SomeQuest",
 "CompleteCondition" : {
   "ItemCondition" : [
     {
       "ItemName": "Coin",
       "Amount": 10
     },
     {
       "ItemName": "Score",
       "Amount": 15,
       "EqualType" : "MoreOrEqual"
     }
   ],
   "LevelCompleteCondition": {
       "LevelName" : "Forest"
   },
   "Any" : {
     "Conditions": [
       {
         "ItemCondition" : [
           {
             "ItemName": "Pig skin",
             "Amount": 10,
             "EqualType" : "MoreOrEqual"
           }
         ]
       },
       {
         "ItemCondition": [
           {
             "ItemName": "Goblin head",
             "Amount": 10,
             "EqualType" : "MoreOrEqual"
           }
         ]
       }
     ]
   }
 }
}

Советы по использованию json

Что делать с вложенностью? Я предлагаю не бояться дублирования. Во-первых, в таком случае проще поддерживать независимые условия, во-вторых, файл выглядит проще.

У вас могут быть, конечно, готовые утилиты для парсинга json. Но в этом случае вы и сами знаете, как вам удобнее его хранить.

Менее очевидный, но самый удобный формат

Мы используем TypeScript. И это, по ряду причин, самый удобный способ работать с такими условиями.

Во-первых, любое изменение имени будет поддерживаться редактором кода, а потому нет необходимости как-то дополнительно это обрабатывать.

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

В-третьих, если понадобится, можно обложить это тестами.

Хотя статья и не про это, не могу не упомянуть главное удобство TypeScript для юнити - возможность обновлять логику, не обновляя билд в сторе.

Заключение

Этот подход зарекомендовал себя в разных проектах. При этом использовался он в самых разных частях игры от ачивок до туториала, а геймдизайнеры могли работать над этим, не привлекая лишний раз программистов. Тем самым требования выполнены.

Комментарии (6)


  1. Jijiki
    12.02.2025 07:43

    спасибо интересная тема, еще на wasm видел пример хорошей отзывчивости(анимация с управлением) в 3д, если Юнити поддерживает wasm поидее тоже подойдёт, классный подход, он очень прост для понимания и может решить вопрос с структурированием данных ведь всё(или почти всё) может хранится в таких блоках структур данных, по моим предположениям это отголоски толстого клиента это ближайшее расширение данного подхода (тоесть - база данных)


  1. SadOcean
    12.02.2025 07:43

    Спасибо за статью, тема достаточно комплексная, вечно выходят костыли.
    Недавно занимался похожими проблемами, и реализовал противоположный подход.
    - Вместо добавления разных реализаций в условия, можно свести все условия к одному типу переменных. То есть у нас есть словарь значений int переменных (если переменной нет, считаем, что значение 0). Таким образом нужно реализовать только проверку типа [переменная] [тип условия] [значение] - "res.hide" >= 10. К сожалению с таким способом будет дупликация переменных, потому что любые значения нужно смапить в этот список. Но логика условий во всех местах будет значительно проще.
    - Для сбора этих переменных можно использовать централизованную шину. В любом месте игры достаточно сделать что-то типа GameEvents.Emit("enemy.boar.killed", 1); чтобы действие было посчитано.
    В этом случае стоить разделить понятие переменной и события, у меня это описывается как список аккумуляторов - какое событие как засчитывается. Таким образом можно одно событие учитывать в нескольких переменных.
    - Систему можно сделать целиком конфигурируемой, если сделать ключи переменных и событий строковыми. Это имеет смысл, если у вас и так много конфигураций. К примеру событие "игрок получил уровень" скорее будет захардкоженным, а вот "противник умер" стоит сделать настраиваемым, и тогда тогда ГД сами опишут, что новый враг - кабан, вызывает событие "кабан умер" и посчитают его в переменных "количество мертвых кабанов" и "количество мертвых врагов".
    - Еще, из опыта, стоит разделять триггеры, вызывающие проверки и сами проверки. Во первых, иногда вам просто нужны проверки, например, если вы проверяете, нужно ли отобразить маркер (условия для него выполнены), это происходит при открытии окна карты, и вам нужны только проверки. Если реагировать на любое изменение переменной, это может вызвать event hell и ваши ГД сделают вам какие нибудь циклические и каскадные события. Количество триггеров стоит ограничить. Обычно это "открыто окно Х" или "битва окончена".

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

    Из минусов можно отметить возможность ошибок в строковых ключах (мы сделали логику в редакторе, рисующую дропдаун по данным из аккумуляторов) и избыточную мощность.
    Геймдизайнеры начинают использовать ее для всего и в итоге у нас есть миллион переменных настроенных снаружи и считаемых непонятно как. Это чревато и отсюда рекомендация разделять тригеры и переменные.


    1. Ipashelovo Автор
      12.02.2025 07:43

      Я бы сказал, что такой подход требует больше работы "вокруг". По крайней мере игра должна быть готова выдавать самые разные значения по стринге. В статье точно не самый лучший инструмент, но достаточно универсальный, чтобы его можно было брать и использовать, если других идей нет.

      Собственно, я потому и не привел варианты работы, например с: реактивами; невозможностью положить дату и обработчики в одну ассембли; случаев, когда требуется не накопить N валюты, а добыть N валюты с начала квеста. Сюда же можно включить адаптивные триггеры проверок. В таком случае, как мне кажется, потеряется универсальность и простота материала туториала


      1. SadOcean
        12.02.2025 07:43

        Тоже верно


  1. fstleo
    12.02.2025 07:43

    Как вариант, PlayerState можно передавать в IsAvailable
    Плюсы:
    - не надо копипастить метод Init и поле _playerState в каждую имплементацию Condition
    - можно сделать единый сервис проверки для разных игроков
    Минусы:
    - нужно иметь доступ к PlayerState в местах проверки, что не всегда удобно


  1. shai_hulud
    12.02.2025 07:43

    ScriptableObject это хорошо, но попробуйте плагин для работы с игровыми данными. Там же можно использовать C# выражения `context.Dungeon.Id == "Woodland"` прямо в JSON данных.