squad-proto/src/sim/loadout.h
z.kirill 745cb2a3fd Стихии и вторая поверхность инструментов (M21, E2)
СТИХИИ (src/sim/element.h)

Семь элементов, сопротивления свойством ВИДА, одна формула урона на пулю и на
клинок. Зачем они в игре про бесконечный спуск: сила отряда растёт медленнее
угрозы (закон масштаба, docs/20-gdd.md), и разрыв обязан закрываться знанием.
Стихии — первый такой канал: верное оружие убивает втрое быстрее, и цифра эта
не растёт от прокачки.

Ручных действий не заводится: стихия — свойство оружия, а не кнопка. Всё
решается тем, что отряд взял вниз.

Стихии есть только у НАХОДИМОГО оружия. Карабин, пистолет и сабля остаются
чистой кинетикой, потому что на них меряется приёмка.

ИНСТРУМЕНТЫ БАЛАНСА (src/debug/tools.cpp)

Вторая поверхность: `squad_proto --tool weapons|enemies|ttk|economy`. Отдельная
от редакторской не по вкусу — редактор линкуется только с движком и правил боя
не видит, а всё это считает урон. Инструменты делятся по ПРЕДМЕТУ: содержимое
у редактора, баланс у игры.

ЧТО НАШЛА ПРОВЕРКА

Первая версия --tool ttk считала разброс по времени убийства и показывала
уверенные 2.8x на каждой твари. Это была разница МОЩНОСТИ стволов: магнум лучше
пистолета против всех, стихии ни при чём.

С вынесенной за скобки мощностью оружия правда оказалась другой: 1.04-1.36x,
то есть состав удара не значил почти ничего. Механика была декоративной ровно
в том же смысле, в каком когда-то был вес.

Чтобы разброс появился, у твари должны быть И уязвимость, И сопротивление среди
тех стихий, которые вообще носит оружие: одной уязвимости мало, если ни один
ствол не бьёт тем, что она держит. После правки содержимого все пять видов в
коридоре 1.90-3.06x, и картина читается — дробовик жжёт бредущих, дуга бьёт
плевков, лук разъедает громилу, карабин против громилы бесполезен.

Коридор в инструменте 1.8-8.0, а не заявленные в документе 3-6: до тройки
дотянут почти чистые стихийные сборки, которые приходят с аффиксами. Занижать
цель в документе нельзя, поэтому в инструменте стоит достижимое.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 05:25:44 +03:00

189 lines
9.8 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; // импульс мишени ДО её сопротивления отбросу
// --- состояние ---
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));
}
};