Как мы разрабатывали свой автобаттлер
3 поста
Предыдущая часть
Ну сделали мы бои все бегает дерется и даже напихали характеристик и механик, чем только у нас персонажи с дальней атакой моментально урон наносят и как то это не комильфо.
Проблема: дДо этого дальники (range ≥ 2) в симуляции работали как ближники: в тик выстрела сразу летели Attack + Damage. В реплее это выглядело странно — лучник делает замах, а урон появляется мгновенно, без полёта болта.
При этом в контенте и дизайне уже была ось melee / ranged, дальность влияла на таргетинг и движение, но не на тайминг урона. Получался разрыв между тем, что игрок ожидает от подобного боя, и тем, что реально происходило в логе.
Модель снаряда
In-flight состояние — простой immutable record:
internal sealed class SimProjectile
{
public int ProjectileId { get; init; }
public int SourceId { get; init; }
public int TargetId { get; init; }
public int LaunchTick { get; init; }
public int HitTick { get; init; }
public int OutgoingDamage { get; init; }
public bool Crit { get; init; }
public DamageKind DamageKind { get; init; }
public Hex FromHex { get; init; }
public Hex ToHex { get; init; }
}
FromHex / ToHex — снимок гексов на момент выстрела (задел под отладку и будущий VFX). В фазе 1 попадание привязано к id цели, а не к координатам. OutgoingDamage записывается при запуске, но на HitTick не используется. Урон пересчитывается заново через ResolveAutoAttackDamage по текущим статам атакующего и цели. Из выстрела «переносится» только флаг крита и тип урона.
Вся логика сосредоточена в ProjectileSystem.cs.
Метод не пишет в лог и не наносит урон. Создаёт id, кладёт снаряд в список, возвращает projectileId:
Событие Attack в лог пишет вызывающий CombatSimulator, не ProjectileSystem.
В начале каждого тика симуляции (до шагов юнитов) проходим список с конца:
for (int i = projectiles.Count - 1; i >= 0; i--)
{
var p = projectiles[i];
if (tick < p.HitTick) continue;
projectiles.RemoveAt(i);
// цель мертва → ProjectileFizzle
// стрелок удалён из списка → ProjectileFizzle
// иначе → ResolveAutoAttackDamage → ApplyProjectileHit → Damage
}
В Simulate() появляется состояние:
int nextProjectileId = 1;
var projectiles = new List<SimProjectile>();
И порядок в тике:
FinalizeArrivals
→ TickProjectiles // сначала долетают старые болты
→ регены, дебаффы, щиты
→ StepUnit // потом новые выстрелы
Ветка Attack() разделена по u.Range:
Melee (range ≤ 1): как раньше — Attack + Damage в одном тике, durationTicks не ставится.
Ranged (range ≥ 2): в тик выстрела — крит, OnAutoAttack-пассивки, мана за атаку, кулдаун, LaunchAutoAttack, в лог Attack с durationTicks и instanceId. Урон — только когда TickProjectiles дойдёт до HitTick.
private static void Attack(SimUnit u, SimUnit target, ...)
{
bool crit = rng.Chance(u.Stats.CritChance);
PassiveRuntimeSystem.OnAutoAttack(u, crit, tick, events, ...);
u.BusyUntilTick = tick + effAttacker.AttackCooldownTicks;
if (u.Range <= 1) { /* мгновенный урон */ return; }
int travel = UnitStats.TravelTicksFromDistance(HexMath.Distance(u.Pos, target.Pos), speed, TicksPerSecond);
int projectileId = ProjectileSystem.LaunchAutoAttack(...);
events.Add(CombatEvent.Attack(tick, u.Id, target.Id, travel, projectileId));
}
Целочисленная, 30 Гц — тот же паттерн, что у скорости атаки и движения: travelTicks = max(1, hexDistance × 30 × 100 / projectileSpeed). projectileSpeed = 600 означает 6.0 гекса в секунду.
Мы не ввели отдельное событие ProjectileLaunch в сим-логе. Переиспользовали Attack:
public static CombatEvent Attack(int tick, int unitId, int targetId, int? travelTicks = null, int? projectileId = null) => new()
{
Tick = tick,
Kind = CombatEventKind.Attack,
UnitId = unitId,
TargetId = targetId,
DurationTicks = travelTicks,
InstanceId = projectileId,
};
Melee: durationTicks = null, instanceId = null
Ranged: durationTicks = travel, instanceId = projectileId
Для промаха — отдельный kind:
ProjectileFizzle(tick, projectileId, sourceId, targetId)
Крит на выстреле, митигация на попадании. Между тиками выстрела и попадания на танка могут наложить щит или shred armor — финальный урон отразит состояние на момент попадания, не выстрела.
Один generic-болт для всех дальников. Быстрее довели до playable. Per-hero спрайты, rotation, trail оставили на потом.
Нет rebuild in-flight при seekTo. Перемотка реплея не пересобирает летящие болты из состояния сима — только из событий с текущего eventIndex.
Нет line-of-sight. Снаряд не проверяет препятствия на луче.
Ну и как обычно ссылка на наш проект: тут
Предыдущий пост: часть 2
Ну вроде ядро готово которо чтото считает и чтото как-то еле-еле управляет состоянием и потоком боя, но бегают у нас болванчики и бьют друг друга с одинаковой скоростью атаки и у всех одинаковое хп. Следующей задачей было расширить базовые характеристики персонажей и добавить обработчики нанесения и получения урона в зависимости от того что мы добавили.
Задача: добавить разные типы урона, добавить модификаторы магического и физического урона, добавить броню и сопротивление магии, добавить ману, добавить модификаторы общенго увеличения нанесенного или уменьшения получаемого урона. Добавить модель рассчета для всего этого дела.
Проблема: да в целом то небыло тут проблем, обычная инженерная задача, небыло тут челленджа просто надо было сделать все в одном месте чтобы небыло избыточности. Ну и оставить возможность расширять все это дело по мере необходимости.
Ну и наконец на третью статью расскажу немного как устроен код и что у нас есть:
Симулятор боя, точка входа во все это дело, передали инпуты с шаблонами юнитов и необходмиые зависимости (сейчас только таргет селектор) и получили лог боя. В нашем случае у нас
Нет инстанса — каждый вызов изолирован.
Нет I/O, нет контента — только UnitSpec[], сид, конфиг боя - все подготовили заранее и посчитали на CPU не прерываясь
Side effect один — append в список CombatEvent - универсальная структура данных для отображения любых евентов боя как часть CombatLog
Детерминизм — SplitMix64 PRNG, порядок юнитов по Id, целая арифметика без даблов и всего такого.
Это не game loop в Unity, а discrete-event simulation с фиксированным шагом 30 Гц или каким угодно в принципе.
public static partial class CombatSimulator
{
internal static CombatLog Simulate(CombatInput input, ITargetSelector selector)
{...}
}
Один Simulate работает по принципу простого цикла, считай каждый тик пока условия завершения боя не выполнены и как посчитал сделай tick++; тут важна анатомия рассчета одного тика: считаем в рамках игровой логики всякие вещи внутри тика и применяем изменения, расширяемость тут это просто добавление новой строчки рассчетов чего-нибудь в тике, таким образом мы можем добавить новую механику, например как видно тут регенерация жизней юнитов, щиты или дебаффы. Конкретно в нашем случае:
Сначала посчитали передвижение и застолбили клетки
Потом посчитали снаряды которые летели, вдруг кого-нибудь убьет
Потом посчитали механики о которых выше сказал
И пошли считать что делают юниты у нас
while (tick < MaxTicks && BothTeamsAlive(units))
{
MovementSystem.FinalizeArrivals(units, tick);
ProjectileSystem.TickProjectiles(...);
CombatEquipApplicator.ApplyTickEquips(...);
TickResourceRegen(units);
TickDebuffExpire(units, tick, events);
TickShieldExpire(units, tick, events);
PassiveRuntimeSystem.OnTick(...);
foreach (var u in units)
{
if (!u.Alive || tick < u.BusyUntilTick) continue;
StepUnit(...);
}
tick++;
}
Сообственно это и есть основа ядра которая позволяет глобально чтото считать, один бой. Что касается персонажей и их состояния то у нас тут двухконтурный подход, есть темплейт, тоесть голые характеристики юнита и его симуляция где применяются все баффы и дебаффы в рамках тика, получается результативное состояние юнита:
internal sealed class SimUnit
{
public int Id { get; }
public string DefId { get; }
public int Team { get; }
public UnitStats Stats { get; private set; }
public Hex Pos { get; set; }
public Hex? MoveDestination { get; set; }
public int Hp { get; set; }
public bool Alive { get; set; } = true;
public int BusyUntilTick { get; set; }
public int MaxHp => Stats.MaxHp;
public int Range => Stats.Range;
public int MaxMana => Stats.MaxMana;
public DamageKind AttackDamageKind { get; }
public HeroClass HeroClass { get; }
public AbilitySpec? Ability { get; }
private readonly List<PassiveSpec> _passives;
public IReadOnlyList<PassiveSpec> Passives => _passives;
public HashSet<string> TriggeredPassives { get; } = new(StringComparer.Ordinal);
public Dictionary<string, int> PassiveCooldowns { get; } = new(StringComparer.Ordinal);
public Dictionary<string, int> ChargeStacks { get; } = new(StringComparer.Ordinal);
public HashSet<string> GrantedPassives { get; } = new(StringComparer.Ordinal);
public Dictionary<string, int> TickAccumulators { get; } = new(StringComparer.Ordinal);
public int Mana { get; set; }
internal int ManaRegenAccumulator { get; set; }
public int ManaGainMultiplier { get; set; }
public int AbilityPowerGainMultiplier { get; set; }
public List<ShieldInstance> Shields { get; } = [];
public List<DebuffInstance> Debuffs { get; } = [];
public DynamicStatMods DynamicMods { get; } = new();
public int StunnedUntilTick { get; set; }
public int UntargetableUntilTick { get; set; }
public int InvulnerableUntilTick { get; set; }
public bool StunImmunePermanent { get; set; }
public int StunBlockCharges { get; set; }
public bool AbilitiesCanCrit { get; set; }
public int AttackSpeedRampBonus { get; set; }
public int AttackSpeedRampTicks { get; set; }
public string? ChannelingAbilityId { get; set; }
public int ChannelEndsAtTick { get; set; }
public ChannelState? ActiveChannel { get; set; }
/// <summary>Текущая цель автоатаки; сбрасывается при смерти/untargetable/выходе из радиуса.</summary>
public int? FocusTargetId { get; set; }
/// <summary>Исключить из перевыбора цель, ушедшую из радиуса (в рамках одного тика).</summary>
public int? RetargetExcludeId { get; set; }
public bool IsStunned(int tick) => !StunImmunePermanent && StunnedUntilTick > tick;
...
}
Ну и есть куча обработчиков этих характеристик и механик которые вызываются в рамках одного тика. Все. Расширяемость получается просто из добавления новых строк в рассчет тика, обработчики и юнита. Конечно рано или поздно (уже=)) это все расползется в портянку и будет сложно и много читать, но ничто не мешает потом распилить это на кучу мелких интерфесов привязанных к определенным механикам. Пока так, в след раз расскажу как мы не продумали дальние атаки и у нас дальние атаки моментально наносили урон, работали как удар в ближнем бою.
Как всегда ссылка на наш проект: Сказкасквад - все еще не хватает моделек но уже можно поиграть горизонтально на телефоне в браузере.
Ну как положено я забыл кликнуть серию по этому это первый пост в серии, но получается второй. Первый пост был тут: Как все начиналось
Погнали, одной из первых задач было сделать архитекутру гейм-сервера который будет крутить бои, но в тоже время не хотелось сразу напихивать кучу механик и характеристик юнитам, ну и вообще за основу взяли шарпы без движка по этому нужно было написать подобие своего.
Задача: спроектировать приложение которое детерменированным образом считало бой между двумя набором абстрактных персонажей на хексовидном поле размером 8х7. Первая итерация должна была содержать минимальный требуемый набор характеристик персонажей хп, скорость атаки, скорость передвижения, силу атаки и ее дальность. Но в тоже время необходимо оставить задел на то что потом будут добавлены умения, различные типы перемещения, различные типы атак и урона, предметы и будут расширены характеристики персонажей которые могут изменяться по ходу боя в ту, или иную сторону.
Проблема: писать свое или не париться и взять Unity и реализовать все на ней, с другой стороны юнити тащит с собой кучу зависимостей и функционала движка который как будто для сервера и не нужен, физика, графика, геометрия итп.
Решение: свой движок который работает по принципу тиков против unity
Конс: Надо делать самим, возможность собрать шишки и детские болезни решения которые уже решены в проверенным временем решении.
Прос: Легковесность своего решения и отсутсвие ненужных зависимостей и в итоге более легкая дистрибуция и быстрый процесс сборки конечного артефакта.
Итог: Сервер, нетворк, рейтинг, матчмейкинг не зависят от 2D/3D/Unity/WebGL
переход с 2.5D на полноценное 3D затрагивает только слой презентации
клиент на старте не считает бой — только проигрывает присланный лог
web-клиент и (будущий) Unity-клиент потребляют один и тот же лог и протокол — прямое доказательство развязки
Свой эмулятор боя — не отказ от Unity как таковой, а правильное разделение ответственности: Unity (или Three.js) рисует, Combat.Core считает. Это ставка на воспроизводимость, анти-чит и инженерный баланс.
Ну и как всегда в конце ссылка на наш проект, оформил серт и теперь мы работаем по https, юрвелкам: https://skazkasquad.ru/
Всем привет, начинаю серию постов о том как мы с товарищем в очередную кукурузу вмотались, ну точнее все-таки занялись геймдевом. Данная серия постов просто будет логом разработчика о том как делали, какие решения принимали и какие технологии использовали.
Немного о себе: Меня зовут Леша, я программный инженер со стажем наверно уже 15+ лет, в компьютерах ковыряюсь с семи лет, учился в красноярском политехе ну и работал наверно курса со второго в ИТ. Прошел всю ветку разраба от стажера до СТО небольшой организации.
Как и многие, я люблю играть в компьютерные игры, раньше даже киберкотлетой был. Мы с товарищем, пусть будет Иван, прилипаем уже некоторое время в TeamfightTactics Duo. Этот сезон нам не сильно зашел и томным вечером в кальянке мы просто решили а давай скопипастим ТФТ и просто посмотрим по силу ли нам это сделать или нет. Ну и слово за слово, понеслось...
Пилить решили все по модному - по вайбкодерски, в целом все у нас при помощи нейронок. Ваня шарпист а я последнее время джавист, в итоге раз он создал репу то и он выбирал стек:
- Бэк пилим на net 8.0
- Фронт на вебе
Для мвп решили что пойдет, ну и что войдет в мвп:
- ФФА на 8 игроков
- Матчмейкинг
- ~60 персонажей на славянскую тематику
- Пве режим на 8 игроков
- Итемизация в целом клон тфт
- Аугментации
- Абилки в ц
- Балансер всего этого дела - тулза которая поможет соблюсти игровой баланс перснажей и предметов
- 2.5d в вебе визуализация
Ну и инженерные задачи уже начались на фазе планирования архитектуры решения. Одна из первых которую предстояло решить это - "А как вообще сделать так чтобы у нас куча боев игралась на клиентах и потом сервер это все собирал в кучу...". Сразу задумались о том что рандом работает везде по разному (ну мы же в будущем хотим в мобилки),и тупо критические удары будут приводить к разным исходам боя в разных местах, а хочется чтобы люди еще и скаутить чужие доски могли, и эта проблема не решается даже при синхронизации сида рандома. Решением стала детерменированность боя, и рассчет всего только на сервере: Сервер рассчитывает бой и передает его на клиенты, которые просто его проигрывают и отображают игроку, на серваке формируется лог боя и просто жсониной по тикам отправляется на клиент, который его проигрывает. Таким образом и с рандомом думать не надо, сервак все посчитает и отдаст, ну и читеров не будет, наверно.
Разобрались с этим ну и вперед, геймсервер парит игроков (пока это все кто заджойнились в матч + недостающие ситы забиваются ботами), и по фазам проигрывает бои и отправляет на клиенты, клиенты смотрят и через команды обновляют стейт сервера, план прост по этому и красив.
Итого проблема: сложность оркестрации различных клиентов и паралельный рассчет боя приводит к несогласованности состояний клиентов и сервера.
Как решили: детерминированный рассчет боя только на сервере и проигрывание реплея боя на клиенте.
Прос: Просто, безопасно с точки зрения читов, поддерживается подобием паттерна CQRS - читаем стейт отправляет комманды с клиента.
Конс: Нагрузка на сеть
Вытекающие: А что делать то если я хочу предмет на персонажа одеть во время боя? А чето тормозит на slow4g или 3g, как то можно побыстрее?
В следующем посту распишу как пилили гейм механики вроде щитов, передвижения персонажей по доске и распределению лута, ну и решали вытекающие.
Если кому-то интересно то сейчас уже можно в веб версию потыкать, тестовый стенд (серт самоподписный пока, не обессудьте) : https://iggq3krru.fvds.ru/