squad-proto/src/sim/loadout.h
z.kirill e3294cffd4 Личинки, парирование и инструмент --wall (M30)
--WALL. Новый прогон отвечает на вопрос, появившийся вместе с чекпоинтами
через пятьдесят этажей: растёт ли достижимая глубина от вылазки к вылазке.
Отряд из одного, трёх и пяти, одетый по глубине, ставится в скопление
тварей и живёт полторы минуты без управления. Первая стена: одиночка D7,
трое D30, пятеро D30 - петля работает.

И он же нашёл настоящую ошибку: PlaceSquad воскрешал ВСЕ пять слотов, в
том числе незанятые. Строка писалась, когда отряд всегда был впятером;
после появления ростера одиночка выходил на этаж с четырьмя невидимыми
телохранителями. Из-за них прежний замер "одиночка выжил на первом этаже,
100 -> 41" был боем впятером.

ЛИЧИНКИ. С исправленным замером выяснилось, что одиночка с ножом
проигрывает двум бредущим: 210 здоровья против ножа это девять секунд
работы под ответным уроном. Первые этажи населены личинками (55 здоровья,
7 урона, медленные), бредущий уехал на третий этаж, бегун на четвёртый.
Новый ВИД, а не ослабленный бредущий с множителем: глубина в этой игре
меняет, кто выходит, а не числа у одного и того же.

ПАРИРОВАНИЕ. Отдельная характеристика клинка, и чем оружие легче, тем она
выше: нож 34%, сабля 20%, тесак 8%. Не выведена из веса намеренно - её
двигает аффикс PARRY, и лёгкий тесак обязан быть возможен как находка.

Отбить можно, только если удары СОПРИКОСНУЛИСЬ: боец сам должен быть в
замахе или в ударе. Иначе клинок стал бы щитом, работающим, пока стоишь.

Удавшийся отбив снимает урон, бьёт тварь ударом бойца целиком и запускает
повторную атаку с начала фазы, НЕ сбрасывая откат. Последнее и есть суть
размена: сбрасывай отбив откат, выгоднее было бы подставляться под удары.

Плевок отбивается тоже: летит назад и несёт урон бойца, а не твари. Это
его удар, просто по летящей цели.

Проверка меряет размен целиком, включая обратную половину правила:
"стоя столбом не отбивает". Без неё условие про соприкосновение
существовало бы только в комментарии.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 05:04:35 +03:00

190 lines
9.9 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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));
}
};