squad-proto/src/tuning.h

460 lines
31 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
#include <algorithm>
// -----------------------------------------------------------------------------
// Единственное место, где живут числа прототипа.
// В коде не должно быть магических констант — всё берётся отсюда.
//
// * блок COMPILE-TIME — размеры буферов/карты, менять только пересборкой;
// * struct Tuning — всё, что подкручивается в рантайме ([ ] выбор, - = шаг).
// -----------------------------------------------------------------------------
// --- compile-time константы --------------------------------------------------
constexpr int INTERNAL_W = 480; // внутреннее разрешение фреймбуфера
constexpr int INTERNAL_H = 270;
constexpr int WINDOW_SCALE = 3; // целочисленный апскейл
constexpr int WINDOW_W = INTERNAL_W * WINDOW_SCALE;
constexpr int WINDOW_H = INTERNAL_H * WINDOW_SCALE;
constexpr int TILE_W = 32; // ромб пола
constexpr int TILE_H = 16;
constexpr int TILE_HALF_W = TILE_W / 2;
constexpr int TILE_HALF_H = TILE_H / 2;
constexpr int WALL_H = 32; // спрайт стены 32x32 (верхний ромб + грани)
constexpr float FIXED_DT = 1.0f / 60.0f; // шаг симуляции
constexpr int MAX_STEPS_PER_FRAME = 5; // защита от спирали смерти
constexpr int MAP_W = 48;
constexpr int MAP_H = 48;
// Точка старта отряда (центральная арена).
constexpr float SQUAD_START_X = 26.5f; // ближе к кучке: дальность стала 7.2
constexpr float SQUAD_START_Y = 26.5f;
constexpr int NUM_AGENTS = 5;
constexpr int NUM_TARGETS = 12;
constexpr int MAX_BULLETS = 512;
// Сколько слотов строя занимают бойцы ближнего боя (слоты 0..N-1).
// В клине это остриё и левое крыло — те, кто и так идёт первым.
constexpr int DEFAULT_MELEE_COUNT = 2;
// Сколько ПОСЛЕДНИХ слотов носят пистолет вместо карабина.
// Короткий ствол уезжает в хвост клина: ему там и место.
constexpr int DEFAULT_PISTOL_COUNT = 1;
// Сколько тайлов пола могут нести запечённую кровь. Каждый — своя копия
// спрайта 32x16 (2 КБ), то есть потолок ~2.4 МБ. Дальше новые пятна ложатся
// только на уже запачканные тайлы: декали вечные, но не бесконечные.
constexpr int BLOOD_TILE_BUDGET = 1200;
// Решатель огневых позиций: 3 кольца x 12 направлений + текущая позиция.
constexpr int SOLVER_RINGS = 3;
constexpr int SOLVER_DIRS = 12;
constexpr int SOLVER_CANDIDATES = SOLVER_RINGS * SOLVER_DIRS + 1;
// --- runtime-тюнинг ----------------------------------------------------------
struct Tuning
{
// движение агента
float moveSpeed = 1.75f; // юнитов/сек: хоррор, а не шутер-беготня
float moveSpeedFast = 3.6f; // догоняют курсор быстро — иначе отряд отстаёт
float steerAccel = 18.0f; // тяжелее; НИЖЕ 16 нельзя — ломается критерий 11.4
float bodyRadius = 0.28f;
float separationRadius = 0.7f;
float wSeek = 1.0f;
float wSeparation = 1.0f;
float wLaneAvoid = 2.4f; // строго выше wSeparation (спек 6.7)
float wWallAvoid = 1.2f;
float wallLookahead = 0.8f;
float wallSlide = 1.4f; // касательная составляющая обхода стен
float arriveRadius = 0.18f;
float turnRateDeg = 300.0f; // ствол тяжёлый
float wTargetPush = 1.0f; // мишень — тело, а не пустое место
float targetPushGap = 0.10f; // зазор поверх суммы радиусов
// оружие (это ПАРАМЕТРЫ КАРАБИНА — оружия по умолчанию, на котором меряются
// критерии приёмки. Остальные стволы — записи каталога sim/weapon_defs.h)
float weaponRange = 7.2f; // -20% от исходных 9.0
float fireInterval = 0.35f;
float laneHalfWidth = 0.10f;
float damage = 12.0f;
float safetyMargin = 0.15f;
float muzzleOffset = 0.3f;
float bulletSpeed = 22.0f;
float bulletRadius = 0.04f;
float bulletKnock = 0.35f; // импульс мишени от попадания пули
float aimToleranceDeg = 5.0f; // ствол должен быть доведён до цели
// --- разброс -------------------------------------------------------------
// Стоя ствол почти не гуляет, на полном ходу гуляет сильно, очередь его
// раскрывает. Тот же конус проверяется на своих перед спуском: правило 6.3
// не ослаблено, оно просто считает по конусу вместо отрезка.
float spreadStandDeg = 0.9f;
float spreadMoveDeg = 4.2f;
float spreadRecoilDeg = 0.55f; // прибавка за выстрел
float spreadRecoilMax = 3.0f;
float spreadRecoilDecay = 3.2f; // градусов/сек оседания
float spreadScale = 1.0f; // общий множитель, ручка для замеров
// --- перезарядка ---------------------------------------------------------
float magazineRifle = 24.0f; // патронов в магазине карабина
float reloadRifle = 1.9f; // сек
// Запас на манёвр союзника за время полёта пули (доля от moveSpeed).
// 0 = верить экстраполяции скорости буквально.
float shotPredictSlack = 0.3f;
// --- ближний бой ---------------------------------------------------------
// Три фазы: замах видно, урон живёт в коротком окне, после удара боец открыт.
float meleeReach = 1.40f; // от центра тела до конца замаха
float meleeArcDeg = 100.0f; // ширина сектора удара
float meleeWindup = 0.17f; // замах — телеграф
float meleeStrike = 0.13f; // активное окно урона
float meleeRecover = 0.24f; // после удара
float meleeInterval = 0.60f; // между началами замахов
float meleeDamage = 130.0f; // 4 удара на мишень против 42 пуль
float meleeBladeDeg = 34.0f; // ширина полосы самого лезвия
float meleeAimToleranceDeg = 30.0f; // допуск разворота на старте замаха
float meleeAllyMargin = 0.14f; // запас к телу союзника в секторе
// На сколько взмахов вперёд смотреть на союзника при старте замаха
// (в долях windup+strike). 0 = верить только текущему положению.
float meleeStartPredict = 1.0f;
// Доля бокового размаха сектора, которую клинок резервирует в реестре линий.
// 1.0 = вся дуга; тогда своим нечем дышать. 0 = резерв шириной с отрезок.
float meleeReserveFrac = 0.55f;
float meleeGap = 0.12f; // зазор при подходе вплотную
float meleeLungeSpeed = 3.4f; // рывок в момент удара, юнитов/сек
float meleeLungeAccel = 120.0f; // рывок обязан успеть за 0.13 с
float meleeWindupScale = 0.20f; // множитель скорости на замахе
float meleeRecoverScale= 0.55f;
float meleeKnockback = 2.4f; // импульс мишени, юнитов/сек
// СВОЙ круг бойца: его отпускают от диска дальше, чем стрелка.
float meleeCohesionRadius = 5.6f;
float meleeSeekRadius = 7.0f; // дальше этого боец за целью не идёт вообще
// Штраф за уже занятую цель, в юнитах дистанции. Двое с клинками, вцепившиеся
// в одного, половину взмахов обрывают друг об друга — пусть расходятся.
float meleeClaimPenalty = 3.0f;
// Сколько держится связка после удара. Не успел продолжить — начинаешь
// серию заново с первого удара.
float meleeComboWindow = 1.10f;
// мишени
float targetHP = 500.0f;
float targetRespawn = 3.0f;
float targetRadius = 0.34f;
float targetKnockDamp = 9.0f; // гашение импульса от удара
float targetReturnRate= 5.0f; // возврат на своё место
// Общий множитель сопротивления отбросу. Само сопротивление у каждой мишени
// своё (sim/target.cpp): тяжёлую клинком не сдвинуть, лёгкая улетает.
float knockResistScale = 1.0f;
// отряд
float holdDelay = 0.22f; // сек без ввода -> HOLDING
float cohesionRadius = 2.1f; // стрелки держатся кучнее
float minEngageDist = 1.6f; // отмасштабировано под дальность 7.2
float formationSpacing = 1.0f; // строй плотнее
float cameraSmooth = 9.0f;
float anchorRadius = 0.3f; // якорь тоже скользит вдоль стен
// Диск отзывчивее бойцов: команда читается мгновенно, а догоняют её ногами.
float anchorSpeed = 3.3f;
float anchorSpeedFast = 4.8f;
float anchorAccel = 20.0f; // разгон/торможение курсора, юнитов/сек^2
// Поводок УПРУГИЙ, а не жёсткий: без него быстрый диск просто отрывается от
// отряда и теряет смысл, но жёсткий упор намертво фиксирует дистанцию, и по
// ней уже не отличить «подправил направление» от «тяну изо всех сил».
// anchorLeash — мягкая граница: до неё курсор идёт свободно;
// anchorLeashMax — жёсткий предел: страховка, до неё обычно не доходит;
// leashDrag — насколько тяжелеет курсор между границами (0..1).
float anchorLeash = 3.0f;
float anchorLeashMax = 5.0f;
float leashDrag = 0.75f; // на жёсткой границе остаётся 25% скорости
// --- натяжение поводка (см. docs/05-squad-ai.md) --------------------------
// Курсор упёрся в поводок, а игрок продолжает давить -> он хочет идти
// быстрее, чем отряд шагает. Это и есть команда «бегом»: отряд ускоряется,
// строй растягивается, а на верхней трети шкалы огонь прекращается вовсе.
float urgeStartFrac = 0.50f; // с какой доли поводка начинается натяжение
float urgeAttack = 2.6f; // как быстро натяжение набирается
float urgeRelease = 3.4f; // и как быстро отпускает
float sprintAt = 0.85f; // выше — спринт: бежим, не атакуем.
// Порог высокий намеренно: при удержании направления натяжение садится
// примерно на 0.54, то есть отряд идёт быстрым шагом И ПРОДОЛЖАЕТ работать
// по целям. Спринт остаётся тем, чем должен быть, — отдельным решением
// (Shift) или следствием того, что курсор реально уехал.
// --- дыхание строя --------------------------------------------------------
float holdSpacing = 0.90f; // на стоянке отряд собирается
float sprintSpacing = 1.30f; // в беге расходится
float sprintStretch = 1.55f; // и вытягивается вдоль движения
float formMorphRate = 4.0f; // скорость перестроения, 1/сек
// решатель огневых позиций (6.5)
float solveInterval = 0.25f;
float commitTime = 0.6f;
float hysteresis = 15.0f;
float ring1 = 0.7f; // кольца решателя сжаты вслед за строем
float ring2 = 1.4f;
float ring3 = 2.1f;
float candMinAlly = 0.60f; // минимум до другого агента (было МЕНЬШЕ диаметра тела)
float sLaneClear = 100.0f;
float sCoverage = 25.0f;
float sBlockAlly = 35.0f;
float sDistCost = 9.0f;
float sCohesionCost = 24.0f; // отбегать от отряда дорого
float sTooClose = 60.0f;
// Жёсткая граница огневой позиции: дальше cohesionRadius + postLeash от диска
// кандидат просто отбраковывается. Одной цены мало — линия огня стоит 100
// очков, и за неё решатель готов уйти сколько угодно далеко. А отряд, из
// которого стрелок ушёл на пять метров, отрядом быть перестаёт.
float postLeash = 1.0f;
// расступание из чужих линий (6.6)
float laneAvoidStrength = 7.0f;
// Ширина РЕЗЕРВА линии относительно порога «тело в линии» из 6.3.
// >1 означает: агента отжимает ДО того, как он влез в чужой сектор.
// Правило стрельбы (6.3) от этого не меняется — см. docs/08-decisions.md.
float laneReserveScale = 2.0f;
// ручное прицеливание (6.8)
float aimConeDeg = 20.0f;
float aimTurnRateDeg = 110.0f; // прицел ведётся тяжелее
// метрики
float uptimeWindow = 10.0f; // окно FIRE UPTIME, сек
float settleQuorum = 4.0f; // сколько агентов нужно для SETTLE TIME
// --- свет и туман войны (render-only, на симуляцию не влияет) -----------
float sightRadius = 7.6f; // где обзор гаснет полностью
float sightFullRadius = 2.2f; // дальше сразу начинается спад: свет должен таять
float sightConeDeg = 100.0f; // конус взгляда агента
float sightConeSoftDeg = 22.0f; // мягкий край конуса
float sightPersonalRadius= 1.9f; // круговой «личный» ореол вокруг агента
float sightBrightness = 0.86f; // яркость видимого; остаток отдан вспышкам
float fogMemoryLevel = 0.13f; // как тускло помнится уже увиденное
float fogWallBleed = 0.92f; // затухание света вглубь блока стен
float fogFadeRate = 9.0f; // сглаживание во времени, чтобы не щёлкало
float fogEntityReveal = 0.30f; // ниже этого света сущность не показывается
float eyeRevealLight = 0.05f; // глаз мишени тлеет почти в полной темноте
float eyeRadiusPx = 3.0f;
float eyeLiftPx = 11.0f; // на уровне «головы» капсулы
float agentMinLight = 0.34f; // свои читаемы всегда: игрок обязан их различать
// вспышки выстрелов как источники света
float flashPower = 0.55f;
float flashRadius = 4.0f;
float flashTime = 0.11f;
// рампа освещения (меняет саму таблицу — требует перепечь)
float lightGamma = 1.45f;
float lightMaxGain = 1.25f;
// --- эффекты стрельбы ---------------------------------------------------
float fxIntensity = 1.0f;
float tracerLength = 1.30f; // длина светящегося штриха, юнитов мира
float bulletLiftPx = 8.0f; // пуля летит на уровне груди, не по земле
float muzzleTime = 0.07f;
float muzzleRadiusPx = 5.0f;
float sparkTime = 0.16f;
float sparkRadiusPx = 4.0f;
float bladeLiftPx = 9.0f; // дуга метёт на уровне груди, не по земле
float bladeTrailFrac = 0.80f; // какая доля пройденной дуги ещё светится
float meleeShake = 1.4f; // отдача камеры на попадание клинком
// --- напряжение и постобработка -----------------------------------------
float dreadRadius = 7.0f; // с какой дистанции мишень начинает пугать
float dreadAttack = 6.0f; // страх набегает быстро
float dreadRelease = 0.5f; // отпускает медленно
float vigStartCalm = 0.42f; // где начинается виньетка
float vigStartTense = 0.20f;
float vigDepthCalm = 0.55f; // насколько темнеет край
float vigDepthTense = 0.85f;
float grainCalm = 6.0f; // амплитуда зерна (макс 127)
float grainTense = 16.0f;
float heartBpmCalm = 58.0f;
float heartBpmTense = 132.0f;
float heartAmp = 0.10f; // насколько сердцебиение давит картинку
float shotFlash = 0.12f; // подсветка кадра на выстрел
float shakePerShot = 0.55f; // отдача камеры, пикселей на выстрел
float shakeMax = 3.0f;
float shakeDecay = 9.0f;
// --- кровь (render-only, декали запекаются в спрайты пола) ---------------
float bloodAmount = 1.0f; // общий множитель размера пятен, 0 = выключить
float bloodRadiusHit = 0.26f; // пуля
float bloodRadiusMelee = 0.50f; // клинок
float bloodRadiusKill = 0.78f; // смерть: лужа
float bloodDrops = 5.0f; // сколько брызг летит по направлению удара
float bloodOpacity = 0.92f; // насколько густо пятно перекрывает пол
// --- дымка и пепел в воздухе (render-only) --------------------------------
float hazeAmount = 0.34f; // сила дымки, 0 = выключить
float hazeScale = 1.0f; // крупность клубов
float hazeDrift = 0.05f; // скорость сноса, экранных ячеек/сек
float hazeGate = 24.0f; // ниже этой яркости дымки нет: тьма остаётся тьмой
float ashDensity = 0.75f; // пылинок на тайл
float ashSpeed = 0.30f; // скорость дрейфа пепла
float ashBrightness = 0.60f;
float ashLiftPx = 13.0f; // на какой высоте висит взвесь
// флаги
bool freeFireSecondary = true; // 6.8: стрелять по вторичным целям в движении
bool friendlyFire = true; // 6.9: всегда true, тумблер только для проверки
};
// --- связка ударов ближнего боя ----------------------------------------------
//
// Чередование «влево-вправо» — это не связка, а метроном: два удара, между
// которыми нет разницы ни на глаз, ни по последствиям. Связка обязана иметь
// ФОРМУ: быстрый вход, злой возврат, тяжёлое завершение.
//
// Множители, а не абсолютные числа: базовые характеристики клинка остаются
// одним набором в Tuning и продолжают крутиться на ходу, а связка их гнёт.
struct MeleeComboStep
{
const char* name;
float arcMul; // ширина сектора
float dmgMul;
float knockMul;
float windupMul; // замах — телеграф; у тяжёлого удара он длиннее
float strikeMul; // активное окно
float recoverMul; // и расплата после
float intervalMul; // пауза до следующего удара связки
bool flipSide; // менять сторону захода лезвия
};
inline const MeleeComboStep MELEE_COMBO[] = {
// 1. Косой заход. Обычный удар, с него всё начинается.
{"slash", 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, 1.00f, true},
// 2. Возврат с обратной стороны. Быстрее и почти без паузы — именно это
// читается как «связка», а не как два отдельных удара.
{"backhand",0.85f, 1.00f, 0.85f, 0.55f, 0.80f, 0.70f, 0.50f, true},
// 3. Раскрутка. Широкая, тяжёлая, с долгим восстановлением: в тесноте её
// просто не дадут начать — сектор не будет чист, и это правильная цена.
{"spin", 1.70f, 1.75f, 2.30f, 1.50f, 1.40f, 1.70f, 1.35f, false},
};
inline constexpr int MELEE_COMBO_STEPS = int(sizeof(MELEE_COMBO) / sizeof(MELEE_COMBO[0]));
inline Tuning g_tune;
// Максимально возможная скорость агента НА ХОДУ. Проверка спуска на время полёта
// пули обязана считать по НЕЙ, иначе рывок союзника не учитывается.
//
// Рывок бойца ближнего боя сюда намеренно не входит: он не «манёвр», а прямой
// отрезок с уже известным направлением, и AllyInterceptsShot проверяет его
// точно — отдельной экстраполяцией (см. ai/firing_solver.cpp).
inline float MaxAgentSpeed() { return std::max(g_tune.moveSpeed, g_tune.moveSpeedFast); }
// --- таблица параметров для подкрутки в оверлее ------------------------------
struct TunableParam
{
const char* name;
float Tuning::* member;
float step;
float minV;
float maxV;
};
inline const TunableParam TUNABLES[] = {
{"safetyMargin", &Tuning::safetyMargin, 0.01f, 0.0f, 1.0f},
{"laneHalfWidth", &Tuning::laneHalfWidth, 0.01f, 0.0f, 0.5f},
{"bodyRadius", &Tuning::bodyRadius, 0.01f, 0.1f, 0.8f},
{"wLaneAvoid", &Tuning::wLaneAvoid, 0.1f, 0.0f, 10.0f},
{"laneAvoidStr", &Tuning::laneAvoidStrength,0.25f, 0.0f, 30.0f},
{"laneReserve", &Tuning::laneReserveScale, 0.05f, 1.0f, 4.0f},
{"predictSlack", &Tuning::shotPredictSlack, 0.05f, 0.0f, 2.0f},
{"wSeparation", &Tuning::wSeparation, 0.1f, 0.0f, 10.0f},
{"steerAccel", &Tuning::steerAccel, 1.0f, 2.0f, 60.0f},
{"moveSpeed", &Tuning::moveSpeed, 0.1f, 0.5f, 10.0f},
{"moveSpeedFast", &Tuning::moveSpeedFast, 0.1f, 0.5f, 12.0f},
{"weaponRange", &Tuning::weaponRange, 0.25f, 2.0f, 20.0f},
{"fireInterval", &Tuning::fireInterval, 0.01f, 0.05f, 2.0f},
{"bulletSpeed", &Tuning::bulletSpeed, 1.0f, 5.0f, 60.0f},
{"solveInterval", &Tuning::solveInterval, 0.05f, 0.05f, 2.0f},
{"commitTime", &Tuning::commitTime, 0.05f, 0.0f, 3.0f},
{"hysteresis", &Tuning::hysteresis, 1.0f, 0.0f, 100.0f},
{"cohesionRadius", &Tuning::cohesionRadius, 0.1f, 1.0f, 12.0f},
{"minEngageDist", &Tuning::minEngageDist, 0.1f, 0.0f, 8.0f},
{"sLaneClear", &Tuning::sLaneClear, 5.0f, 0.0f, 400.0f},
{"sCoverage", &Tuning::sCoverage, 1.0f, 0.0f, 200.0f},
{"sBlockAlly", &Tuning::sBlockAlly, 1.0f, 0.0f, 200.0f},
{"sDistCost", &Tuning::sDistCost, 0.5f, 0.0f, 100.0f},
{"sCohesionCost", &Tuning::sCohesionCost, 0.5f, 0.0f, 100.0f},
{"sTooClose", &Tuning::sTooClose, 2.0f, 0.0f, 300.0f},
{"postLeash", &Tuning::postLeash, 0.1f, 0.0f, 10.0f},
{"formSpacing", &Tuning::formationSpacing, 0.05f, 0.4f, 4.0f},
{"anchorSpeed", &Tuning::anchorSpeed, 0.1f, 0.5f, 12.0f},
{"anchorSpeedFast",&Tuning::anchorSpeedFast, 0.1f, 0.5f, 16.0f},
{"anchorLeash", &Tuning::anchorLeash, 0.1f, 0.2f, 12.0f},
{"anchorLeashMax", &Tuning::anchorLeashMax, 0.1f, 0.4f, 16.0f},
{"leashDrag", &Tuning::leashDrag, 0.05f, 0.0f, 1.0f},
{"anchorAccel", &Tuning::anchorAccel, 1.0f, 2.0f, 80.0f},
// --- натяжение и строй ---
{"urgeStart", &Tuning::urgeStartFrac, 0.05f, 0.0f, 0.95f},
{"urgeAttack", &Tuning::urgeAttack, 0.2f, 0.1f, 20.0f},
{"urgeRelease", &Tuning::urgeRelease, 0.2f, 0.1f, 20.0f},
{"sprintAt", &Tuning::sprintAt, 0.02f, 0.05f, 1.0f},
{"holdSpacing", &Tuning::holdSpacing, 0.02f, 0.4f, 2.0f},
{"sprintSpacing", &Tuning::sprintSpacing, 0.02f, 0.4f, 3.0f},
{"sprintStretch", &Tuning::sprintStretch, 0.05f, 0.5f, 4.0f},
{"formMorph", &Tuning::formMorphRate, 0.25f, 0.25f, 20.0f},
// --- разброс и перезарядка ---
{"spreadStand", &Tuning::spreadStandDeg, 0.1f, 0.0f, 20.0f},
{"spreadMove", &Tuning::spreadMoveDeg, 0.1f, 0.0f, 30.0f},
{"spreadRecoil", &Tuning::spreadRecoilDeg, 0.05f, 0.0f, 10.0f},
{"spreadRecMax", &Tuning::spreadRecoilMax, 0.1f, 0.0f, 20.0f},
{"spreadDecay", &Tuning::spreadRecoilDecay,0.2f, 0.1f, 40.0f},
{"spreadScale", &Tuning::spreadScale, 0.05f, 0.0f, 4.0f},
{"magazine", &Tuning::magazineRifle, 1.0f, 1.0f, 200.0f},
{"reloadTime", &Tuning::reloadRifle, 0.05f, 0.1f, 8.0f},
{"bulletKnock", &Tuning::bulletKnock, 0.05f, 0.0f, 6.0f},
{"knockResist", &Tuning::knockResistScale, 0.05f, 0.0f, 2.0f},
// --- ближний бой ---
{"meleeReach", &Tuning::meleeReach, 0.05f, 0.4f, 4.0f},
{"meleeArc", &Tuning::meleeArcDeg, 5.0f, 20.0f, 300.0f},
{"meleeWindup", &Tuning::meleeWindup, 0.01f, 0.02f, 1.0f},
{"meleeStrike", &Tuning::meleeStrike, 0.01f, 0.02f, 1.0f},
{"meleeRecover", &Tuning::meleeRecover, 0.01f, 0.0f, 1.5f},
{"meleeInterval", &Tuning::meleeInterval, 0.02f, 0.1f, 3.0f},
{"meleeDamage", &Tuning::meleeDamage, 5.0f, 1.0f, 999.0f},
{"meleeLunge", &Tuning::meleeLungeSpeed, 0.1f, 0.0f, 12.0f},
{"meleeKnock", &Tuning::meleeKnockback, 0.1f, 0.0f, 12.0f},
{"meleeCohesion", &Tuning::meleeCohesionRadius, 0.2f, 1.0f, 20.0f},
{"meleeAllyMargin",&Tuning::meleeAllyMargin, 0.02f, 0.0f, 1.0f},
{"meleePredict", &Tuning::meleeStartPredict,0.1f, 0.0f, 3.0f},
{"meleeReserve", &Tuning::meleeReserveFrac, 0.05f, 0.0f, 1.5f},
{"meleeShake", &Tuning::meleeShake, 0.1f, 0.0f, 6.0f},
// --- хоррор-часть ---
{"sightRadius", &Tuning::sightRadius, 0.25f, 2.0f, 24.0f},
{"sightCone", &Tuning::sightConeDeg, 5.0f, 20.0f, 360.0f},
{"sightPersonal", &Tuning::sightPersonalRadius, 0.1f, 0.5f, 10.0f},
{"fogMemory", &Tuning::fogMemoryLevel, 0.02f, 0.0f, 1.0f},
{"fogReveal", &Tuning::fogEntityReveal, 0.02f, 0.0f, 1.0f},
{"flashPower", &Tuning::flashPower, 0.05f, 0.0f, 2.0f},
{"lightGamma", &Tuning::lightGamma, 0.05f, 0.3f, 4.0f},
{"lightGain", &Tuning::lightMaxGain, 0.05f, 0.2f, 3.0f},
{"fxIntensity", &Tuning::fxIntensity, 0.1f, 0.0f, 4.0f},
{"tracerLength", &Tuning::tracerLength, 0.1f, 0.1f, 6.0f},
{"grainTense", &Tuning::grainTense, 1.0f, 0.0f, 100.0f},
{"vigDepthTense", &Tuning::vigDepthTense, 0.05f, 0.0f, 1.0f},
{"dreadRadius", &Tuning::dreadRadius, 0.5f, 1.0f, 20.0f},
{"shakePerShot", &Tuning::shakePerShot, 0.05f, 0.0f, 4.0f},
// --- кровь, дымка, пепел ---
{"bloodAmount", &Tuning::bloodAmount, 0.05f, 0.0f, 3.0f},
{"bloodOpacity", &Tuning::bloodOpacity, 0.02f, 0.0f, 1.0f},
{"bloodDrops", &Tuning::bloodDrops, 1.0f, 0.0f, 16.0f},
{"hazeAmount", &Tuning::hazeAmount, 0.02f, 0.0f, 2.0f},
{"hazeScale", &Tuning::hazeScale, 0.05f, 0.2f, 4.0f},
{"hazeDrift", &Tuning::hazeDrift, 0.01f, 0.0f, 1.0f},
{"hazeGate", &Tuning::hazeGate, 1.0f, 0.0f, 128.0f},
{"ashDensity", &Tuning::ashDensity, 0.05f, 0.0f, 4.0f},
{"ashSpeed", &Tuning::ashSpeed, 0.05f, 0.0f, 3.0f},
{"ashBright", &Tuning::ashBrightness, 0.05f, 0.0f, 2.0f},
{"ashLift", &Tuning::ashLiftPx, 1.0f, 0.0f, 40.0f},
};
inline constexpr int TUNABLE_COUNT = int(sizeof(TUNABLES) / sizeof(TUNABLES[0]));