Часть 4: проблема с дальними атаками
Предыдущая часть
Ну сделали мы бои все бегает дерется и даже напихали характеристик и механик, чем только у нас персонажи с дальней атакой моментально урон наносят и как то это не комильфо.
Проблема: дДо этого дальники (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: два метода, одна ответственность
Вся логика сосредоточена в ProjectileSystem.cs.
LaunchAutoAttack — только enqueue
Метод не пишет в лог и не наносит урон. Создаёт id, кладёт снаряд в список, возвращает projectileId:
Событие Attack в лог пишет вызывающий CombatSimulator, не ProjectileSystem.
TickProjectiles — resolve на HitTick
В начале каждого тика симуляции (до шагов юнитов) проходим список с конца:
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
}
Где это вшито в CombatSimulator
В 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 гекса в секунду.
Контракт лога: один Attack, два смысла
Мы не ввели отдельное событие 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. Снаряд не проверяет препятствия на луче.
Ну и как обычно ссылка на наш проект: тут
Картинку хоть приложили бы, вообще не понятно о чем речь.
Заходишь по ссылке, какая то регистрация.
Текст непонятно о чем.
Без негатива, но подача поста максимально душная
В начале лучше писать что за игра, ниже геймплей, что вы поменяли, выстрелы, показывайте выстрелы, ниже краткое описание без духоты. И ссылка без регистрации