--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке. Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление тварей и живёт полторы минуты без управления. Первая стена: одиночка D7, трое D30, пятеро D30 - петля работает. И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в том числе незанятые. Строка писалась, когда отряд всегда был впятером; после появления ростера одиночка выходил на этаж с четырьмя невидимыми телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже, 100 -> 41" был боем впятером. ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом проигрывает двум бредущим: 210 здоровья против ножа это девять секунд работы под ответным уроном. Первые этажи населены личинками (55 здоровья, 7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый. Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре меняет, кто выходит, а не числа у одного и того же. ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка. Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь. Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары. Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это его удар, просто по летящей цели. Проверка меряет размен целиком, включая обратную половину правила: "стоя столбом не отбивает". Без неё условие про соприкосновение существовало бы только в комментарии. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
190 lines
9.9 KiB
C++
190 lines
9.9 KiB
C++
#pragma once
|
||
#include "sim/element.h"
|
||
// -----------------------------------------------------------------------------
|
||
// Снаряжение бойца: РАБОЧЕЕ состояние оружия.
|
||
//
|
||
// Разделение простое и важное:
|
||
// * ЧТО такое ствол (дальность, урон, разброс, магазин, вид вспышки) —
|
||
// это FirearmDef на сущности-оружии, sim/weapon_defs.h;
|
||
// * В КАКОМ ОН СЕЙЧАС СОСТОЯНИИ (откат, патроны, перезарядка, увод ствола) —
|
||
// это Firearm здесь, и живёт оно на бойце.
|
||
// Подменили сущность оружия — поменялись все статы разом, состояние обнулилось,
|
||
// и ни одно правило боя об этом не узнало.
|
||
//
|
||
// Правила боя от класса НЕ зависят. И стрелок, и боец ближнего боя обязаны
|
||
// проверить своих перед ударом; меняется только геометрия проверки —
|
||
// конус «дуло -> цель» против сектора замаха (docs/11-melee.md).
|
||
// -----------------------------------------------------------------------------
|
||
#include <cstdint>
|
||
|
||
#include "engine/hash.h"
|
||
#include "engine/math.h"
|
||
#include "sim/weapon_defs.h"
|
||
#include "tuning.h"
|
||
|
||
// --- дальнобойное ------------------------------------------------------------
|
||
|
||
struct Firearm
|
||
{
|
||
FirearmDef def{}; // копия статов сущности-оружия, обновляется каждый шаг
|
||
|
||
// --- состояние ---
|
||
float cooldown = 0.0f;
|
||
int ammo = 0;
|
||
float reloadTimer = 0.0f; // > 0 — идёт перезарядка
|
||
float recoilDeg = 0.0f; // увод ствола, гаснет сам
|
||
uint32_t shotSeq = 0; // номер выстрела: сеет детерминированный разброс
|
||
|
||
bool Reloading() const { return reloadTimer > 0.0f; }
|
||
bool Empty() const { return ammo <= 0; }
|
||
|
||
// Доля магазина — для оверлея и HUD.
|
||
float AmmoFrac() const
|
||
{
|
||
return (def.magazine > 0) ? Clampf(float(ammo) / float(def.magazine), 0.0f, 1.0f) : 0.0f;
|
||
}
|
||
|
||
// Разброс в радианах: стоя — минимальный, на ходу растёт до spreadMoveDeg,
|
||
// сверху ложится накопленный увод ствола.
|
||
//
|
||
// Это ЕДИНСТВЕННОЕ место, где считается разброс. Его же читает проверка
|
||
// линии огня: конус, по которому реально уйдёт пуля, обязан быть тем самым
|
||
// конусом, который проверили на своих (см. ai/firing_solver.cpp).
|
||
float SpreadRad(float speed) const
|
||
{
|
||
const float m = Clampf(speed / std::max(0.01f, MaxAgentSpeed()), 0.0f, 1.0f);
|
||
const float deg = Lerpf(def.spreadStandDeg, def.spreadMoveDeg, m) + recoilDeg;
|
||
return std::max(0.0f, deg) * DEG2RAD_F * std::max(0.0f, g_tune.spreadScale);
|
||
}
|
||
|
||
// Отклонение конкретного выстрела внутри конуса. Не генератор: чистый хеш
|
||
// от (владелец, номер выстрела), поэтому прогон воспроизводим побитово.
|
||
// pellet различает дробины одного залпа: без него вся картечь ушла бы
|
||
// в одну точку и была бы неотличима от одиночного выстрела.
|
||
float ShotOffset(int ownerIndex, float spreadRad, int pellet = 0) const
|
||
{
|
||
const uint32_t h = Hash3(uint32_t(ownerIndex) * 2654435761u, shotSeq,
|
||
uint32_t(pellet) * 0x85EBCA6Bu);
|
||
// Треугольное распределение: центр конуса вероятнее краёв.
|
||
const float a = HashSigned(h);
|
||
const float b = HashSigned(HashU32(h ^ 0x9E3779B9u));
|
||
return 0.5f * (a + b) * spreadRad;
|
||
}
|
||
};
|
||
|
||
// --- ближний бой -------------------------------------------------------------
|
||
|
||
// Три фазы, как в любом ощутимом ударе: замах видно заранее, урон живёт
|
||
// в коротком окне, после удара боец открыт. Без разделения удар превращается
|
||
// в мгновенный хитскан и не читается на 480x270.
|
||
enum class MeleeState : uint8_t
|
||
{
|
||
IDLE = 0, // подходит или ждёт перезарядку
|
||
WINDUP, // замах: ноги вкопаны, ось удара уже зафиксирована
|
||
STRIKE, // активное окно: лезвие метёт дугу, есть рывок вперёд
|
||
RECOVER, // после удара — уязвим и медленный
|
||
};
|
||
|
||
struct MeleeWeapon
|
||
{
|
||
// Из чего состоит удар клинка. Копируется с сущности-оружия, как и всё
|
||
// остальное ниже.
|
||
ElementMix elem;
|
||
|
||
// --- параметры (копируются с сущности-оружия каждый шаг) ---
|
||
float reach = 1.15f; // от центра тела до конца замаха
|
||
float arcDeg = 100.0f; // ширина сектора
|
||
float windup = 0.18f;
|
||
float strike = 0.12f;
|
||
float recover = 0.26f;
|
||
float interval = 0.62f; // между началами замахов
|
||
float damage = 110.0f;
|
||
float knockback = 2.4f; // импульс мишени ДО её сопротивления отбросу
|
||
float parry = 0.0f; // шанс отбить чужой удар (sim/weapon_defs.h)
|
||
|
||
// --- состояние ---
|
||
MeleeState state = MeleeState::IDLE;
|
||
float phase = 0.0f; // остаток текущей фазы, сек
|
||
float cooldown = 0.0f;
|
||
float dir = 0.0f; // ось удара, зафиксирована на замахе
|
||
float sweepSign = 1.0f; // с какой стороны заходит лезвие (чередуется)
|
||
int targetId = -1;
|
||
bool inReach = false; // цель в зоне удара прямо сейчас
|
||
bool arcBlockedByAlly = false; // в секторе свой -> бить нельзя (аналог 6.3)
|
||
// Кого уже задели ЭТИМ взмахом. Шестьдесят четыре бита, а не тридцать два:
|
||
// ширина маски — это и есть потолок числа целей в мире (MAX_TARGETS), а
|
||
// этажи на глубине упираются именно в него.
|
||
uint64_t hitMask = 0;
|
||
|
||
// --- связка ---
|
||
int comboStep = 0; // какой удар связки будет следующим
|
||
float comboTimer = 0.0f; // сколько ещё держится связка
|
||
|
||
// --- запечённые параметры ТЕКУЩЕГО взмаха ---
|
||
// Связка гнёт ширину, урон и тайминги, а поля выше переписываются из
|
||
// снаряжения КАЖДЫЙ шаг (SyncLoadoutFromTuning). Поэтому значения взмаха
|
||
// фиксируются на старте замаха и живут отдельно — иначе удар менял бы
|
||
// свойства прямо в полёте.
|
||
float curHalfArc = 0.0f;
|
||
float curDamage = 0.0f;
|
||
float curKnock = 0.0f;
|
||
float curWindup = 0.0f;
|
||
float curStrike = 0.0f;
|
||
float curRecover = 0.0f;
|
||
|
||
bool Committed() const
|
||
{
|
||
return state == MeleeState::WINDUP || state == MeleeState::STRIKE;
|
||
}
|
||
|
||
const MeleeComboStep& NextComboStep() const
|
||
{
|
||
return MELEE_COMBO[(comboStep >= 0 && comboStep < MELEE_COMBO_STEPS) ? comboStep : 0];
|
||
}
|
||
|
||
// Полуширина сектора. Во время взмаха — запечённая, иначе — та, что будет
|
||
// у СЛЕДУЮЩЕГО удара связки. Все потребители (проверка сектора, дуга,
|
||
// оверлей, резерв линии) обязаны видеть одно и то же число: иначе
|
||
// нарисованное и то, что бьёт, разъедутся.
|
||
float HalfArc() const
|
||
{
|
||
if (Committed() || state == MeleeState::RECOVER) return curHalfArc;
|
||
return arcDeg * 0.5f * DEG2RAD_F * NextComboStep().arcMul;
|
||
}
|
||
|
||
// Запечь параметры взмаха из базовых характеристик и шага связки.
|
||
void BakeSwing(const MeleeComboStep& cs)
|
||
{
|
||
curHalfArc = arcDeg * 0.5f * DEG2RAD_F * cs.arcMul;
|
||
curDamage = damage * cs.dmgMul;
|
||
curKnock = knockback * cs.knockMul;
|
||
curWindup = windup * cs.windupMul;
|
||
curStrike = strike * cs.strikeMul;
|
||
curRecover = recover * cs.recoverMul;
|
||
}
|
||
|
||
float PhaseLen() const
|
||
{
|
||
switch (state)
|
||
{
|
||
case MeleeState::WINDUP: return curWindup;
|
||
case MeleeState::STRIKE: return curStrike;
|
||
case MeleeState::RECOVER: return curRecover;
|
||
default: return 1.0f;
|
||
}
|
||
}
|
||
|
||
// 0 в начале фазы, 1 в конце.
|
||
float Progress() const
|
||
{
|
||
return Clampf(1.0f - phase / std::max(1e-4f, PhaseLen()), 0.0f, 1.0f);
|
||
}
|
||
|
||
// Где лезвие при заданном прогрессе взмаха: от одного края сектора к другому.
|
||
float BladeAngleAt(float progress) const
|
||
{
|
||
const float half = curHalfArc;
|
||
return WrapPi(dir + sweepSign * (-half + 2.0f * half * progress));
|
||
}
|
||
};
|