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