squad-proto/src/sim/loadout.h

178 lines
9.0 KiB
C
Raw Normal View History

Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (raylib только окно, ввод и финальный блит). Ядро (спек, раздел 6): - LaneClear: проверка линии огня по своим, стенам и дальности; - решатель огневых позиций с гистерезисом и коммитом; - резервирование линий огня и расступание из чужих секторов; - пули-снаряды, дружественный урон физически возможен. Хоррор-слой (render-only): свет и туман войны с памятью карты, светящиеся трассеры, кровь, виньетка, зерно, напряжение. Ближний бой: сектор удара, три фазы, связка из трёх ударов, своя дисциплина «не бить сквозь своего». Проверка: squad_proto.exe --accept прогоняет критерии приёмки раздела 11 плюс блок M7 по ближнему бою — все PASS, FRIENDLY_HITS = 0 за 3 минуты боя. Сборка: cmake -S . -B build && cmake --build build --config Release raylib 6.0 подтягивается через FetchContent. Документация — CLAUDE.md как оглавление, содержание в docs/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:49 +07:00
#pragma once
// -----------------------------------------------------------------------------
// Снаряжение бойца: РАБОЧЕЕ состояние оружия.
//
// Разделение простое и важное:
// * ЧТО такое ствол (дальность, урон, разброс, магазин, вид вспышки) —
// это FirearmDef на сущности-оружии, sim/weapon_defs.h;
// * В КАКОМ ОН СЕЙЧАС СОСТОЯНИИ (откат, патроны, перезарядка, увод ствола) —
// это Firearm здесь, и живёт оно на бойце.
// Подменили сущность оружия — поменялись все статы разом, состояние обнулилось,
// и ни одно правило боя об этом не узнало.
//
// Правила боя от класса НЕ зависят. И стрелок, и боец ближнего боя обязаны
// проверить своих перед ударом; меняется только геометрия проверки —
// конус «дуло -> цель» против сектора замаха (docs/11-melee.md).
// -----------------------------------------------------------------------------
#include <cstdint>
#include "core/hash.h"
#include "core/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);
}
// Отклонение конкретного выстрела внутри конуса. Не генератор: чистый хеш
// от (владелец, номер выстрела), поэтому прогон воспроизводим побитово.
float ShotOffset(int ownerIndex, float spreadRad) const
{
const uint32_t h = Hash2(uint32_t(ownerIndex) * 2654435761u, shotSeq);
// Треугольное распределение: центр конуса вероятнее краёв.
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
{
// --- параметры (копируются с сущности-оружия каждый шаг) ---
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; // импульс мишени ДО её сопротивления отбросу
// --- состояние ---
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)
uint32_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));
}
};