squad-proto/src/sim/target.cpp
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

367 lines
19 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.

#include "sim/target.h"
namespace
{
// Кучки: 5 на арене (рядом со стартом), 3 в северной комнате,
// 3 в западной, 1 одиночная в восточной. Всего 12.
//
// Северная кучка стоит НАПРОТИВ прохода x=30..31: из коридора до неё есть
// чистая линия. Это тот самый тест-кейс из спека — в узком проходе правило
// «не стрелять сквозь своих» упирается в геометрию, и отряд обязан вытянуться
// в колонну: стреляет передний, задние молчат.
//
// Кучка обязана быть В ПРЕДЕЛАХ ДАЛЬНОСТИ от середины прохода (y≈14), иначе
// стрелять из коридора не по чему и тест-кейс проверяет пустоту. При weaponRange
// 7.2 ближняя мишень стоит в ~6.5 юнита: запас есть, но небольшой — если менять
// дальность, эти координаты надо пересчитывать вместе с ней.
const float kTargetSpots[NUM_TARGETS][2] = {
{30.5f, 20.5f}, {31.5f, 22.5f}, {29.5f, 23.5f}, {32.5f, 21.5f}, {30.5f, 24.5f},
{29.5f, 8.5f}, {31.5f, 7.5f}, {27.5f, 10.5f},
{ 7.5f, 40.5f}, { 9.5f, 42.5f}, { 6.5f, 43.5f},
{43.5f, 35.5f},
};
// Сопротивление отбросу по слотам. Не случайное: в каждой кучке есть кто-то
// тяжёлый, кого клинком не сдвинуть, и кто-то лёгкий, кто улетает от первого
// же удара. Иначе «отброс» и «сопротивление» — два числа, которых не видно.
const float kTargetKnockResist[NUM_TARGETS] = {
0.00f, 0.65f, 0.15f, 0.40f, 0.00f,
0.55f, 0.10f, 0.30f,
0.20f, 0.70f, 0.05f,
0.85f,
};
// Кто стоит в каждой точке в боевом режиме. Ближняя кучка — та, на которую
// отряд наткнётся первой: бегун, двое бредущих, плевок и ещё бегун. Громилы
// сидят в дальних комнатах: до них надо дойти.
const EnemyKind kTargetKinds[NUM_TARGETS] = {
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::RUSHER,
EnemyKind::SHAMBLER, EnemyKind::SPITTER, EnemyKind::BRUTE,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::SPITTER,
EnemyKind::BRUTE,
};
// Твари СВЕРХ мишеней: расставлены по всей карте, а не только у старта.
// Без них зачистка первой кучки заканчивала вылазку, и карта стояла пустой.
// В песочнице приёмки их нет вообще — та расстановка не менялась.
const float kExtraSpots[NUM_EXTRA_ENEMIES][2] = {
{14.5f, 12.5f}, {18.5f, 16.5f}, {12.5f, 24.5f},
{36.5f, 12.5f}, {40.5f, 18.5f}, {38.5f, 27.5f},
{21.5f, 36.5f}, {25.5f, 41.5f}, {33.5f, 39.5f},
{16.5f, 32.5f}, {30.5f, 33.5f}, {41.5f, 43.5f},
};
// Хваты стоят ПОДАЛЬШЕ от старта, в глубине карты: захват — это ловушка для
// отряда, который уже разошёлся по позициям, а не встречающий у порога.
const EnemyKind kExtraKinds[NUM_EXTRA_ENEMIES] = {
EnemyKind::SHAMBLER, EnemyKind::RUSHER, EnemyKind::SHAMBLER,
EnemyKind::SPITTER, EnemyKind::SHAMBLER, EnemyKind::RUSHER,
EnemyKind::LURKER, EnemyKind::BRUTE, EnemyKind::SPITTER,
EnemyKind::RUSHER, EnemyKind::SHAMBLER, EnemyKind::LURKER,
};
// Разрушаемая обстановка. Стоит ПАРАМИ и по краям комнат, а не по центру:
// брошенный скарб жмётся к стенам, и ровно поэтому он читается как обстановка,
// а не как расставленные мишени. Две штуки — у самого старта: игрок обязан
// узнать, что хлам ломается, до того как в него полетит первая тварь.
const float kPropSpots[NUM_PROPS][2] = {
{24.5f, 24.5f}, {23.5f, 25.5f}, // у старта: пара ящиков под рукой
{33.5f, 19.5f}, // ближняя кучка: укрытие сбоку
{28.5f, 6.5f}, {26.5f, 6.5f}, // северная комната, вдоль стены
{ 5.5f, 41.5f}, // западная комната
{44.5f, 33.5f}, {42.5f, 44.5f}, // восточная и юго-восточный угол
};
const EnemyKind kPropKinds[NUM_PROPS] = {
EnemyKind::CRATE, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::LOCKER, EnemyKind::BARREL,
EnemyKind::CRATE,
EnemyKind::BARREL, EnemyKind::LOCKER,
};
} // namespace
EnemyDef EnemyDefOf(EnemyKind kind)
{
EnemyDef d;
switch (kind)
{
// Бредущий: основа толпы. Медленный настолько, что от него можно уйти
// шагом, — и ровно поэтому страшен только гурьбой.
case EnemyKind::SHAMBLER:
d.name = "SHAMBLER";
d.hp = 210.0f; d.radius = 0.34f; d.speed = 0.95f; d.knockResist = 0.25f;
d.aggroRadius = 9.5f; d.attackRange = 0.85f; d.damage = 13.0f;
d.windup = 0.42f; d.interval = 1.25f;
// Взмах лапой: широкий сектор перед собой. Отходить от него назад
// бесполезно — он и так короткий; уходить надо вбок.
d.attack = AttackKind::SWIPE; d.swipeArcDeg = 100.0f;
d.lightPower = 0.05f;
d.r = 116; d.g = 52; d.b = 52;
// мясо: горит как солома, дугу проводит плохо, радиация его и подняла
d.resist = Resists(0.0f, -0.60f, 0.0f, 0.0f, 0.60f, 0.30f, 0.0f);
break;
// Бегун: тонкий и быстрый. Успевает добежать до перезарядки — и тогда
// стрелок остаётся с ним один на один.
case EnemyKind::RUSHER:
d.name = "RUSHER";
d.hp = 110.0f; d.radius = 0.27f; d.speed = 3.3f; d.knockResist = 0.0f;
d.aggroRadius = 12.0f; d.attackRange = 0.75f; d.damage = 14.0f;
d.windup = 0.28f; d.interval = 1.10f;
// Рывок: разрыв в три метра он закрывает за пятую долю секунды и
// сносит ВСЕХ по прямой. Замах длиннее прежнего удара намеренно —
// это единственное окно, в котором отряд успевает разойтись с оси.
d.attack = AttackKind::LUNGE; d.lungeDist = 3.4f; d.lungeTime = 0.20f;
d.lightPower = 0.07f;
d.r = 158; d.g = 74; d.b = 44;
// быстрый и тонкий: горит и стынет, к кислоте привычен
d.resist = Resists(0.0f, -0.50f, -0.40f, 0.55f, 0.0f, 0.0f, 0.0f);
break;
// Плевок: единственный, кто достаёт издалека. Держит дистанцию и бьёт
// по площадям — из-за него отряд не может просто встать в линию.
case EnemyKind::SPITTER:
d.name = "SPITTER";
d.hp = 110.0f; d.radius = 0.31f; d.speed = 1.15f; d.knockResist = 0.1f;
d.aggroRadius = 10.5f; d.attackRange = 6.2f; d.damage = 16.0f;
d.windup = 0.55f; d.interval = 1.60f;
d.ranged = true; d.projectileSpeed = 8.5f;
// Снаряд летит дальше, чем плевок «достаёт»: отойти от него на шаг
// мало, надо разрывать дистанцию или прятаться за стену.
d.attack = AttackKind::SPIT; d.spitRange = 8.4f;
d.lightPower = 0.10f;
d.r = 96; d.g = 132; d.b = 72;
// сам кислотный: TOX по нему бесполезен, дуга — наоборот
d.resist = Resists(0.10f, 0.0f, 0.0f, 0.70f, -0.45f, 0.20f, 0.0f);
break;
// Громила: медленный, толстый, с одного удара снимает половину бойца.
// Клинком его надо разбирать вдвоём, и это правильная цена.
case EnemyKind::BRUTE:
d.name = "BRUTE";
d.hp = 520.0f; d.radius = 0.46f; d.speed = 1.05f; d.knockResist = 0.85f;
d.aggroRadius = 8.5f; d.attackRange = 1.25f; d.damage = 46.0f;
d.windup = 0.95f; d.interval = 2.60f;
// Удар оземь: круг вокруг себя, а не выпад в жертву. Разбирать
// громилу вдвоём теперь стоит денег — оба стоят в круге.
d.attack = AttackKind::SLAM; d.slamRadius = 2.15f;
d.lightPower = 0.12f;
d.r = 142; d.g = 60; d.b = 78;
// танк: пуля вязнет в туше, кислота разъедает
d.resist = Resists(0.60f, 0.20f, 0.30f, -0.50f, 0.0f, 0.10f, 0.0f);
break;
// Хват: сам по себе слаб, но держит бойца на месте, пока его не убьют.
// Опасен не уроном, а тем, что вырывает одного из строя: линия огня
// рушится, отряд разваливается, и остальные твари доедают.
case EnemyKind::LURKER:
d.name = "LURKER";
d.hp = 150.0f; d.radius = 0.30f; d.speed = 1.90f; d.knockResist = 0.15f;
d.aggroRadius = 11.0f; d.attackRange = 0.90f; d.damage = 9.0f;
d.windup = 0.50f; d.interval = 2.40f;
// Урон тикает, пока держит: 9 за тик каждые 0.45 с — само по себе
// терпимо, смертельна именно неподвижность.
d.attack = AttackKind::GRAB; d.grabTime = 2.60f; d.grabTick = 0.45f;
d.lightPower = 0.09f;
d.r = 88; d.g = 62; d.b = 118;
// тварь тени: жгут, а не расстреливают; пустота ей почти родня
d.resist = Resists(0.30f, -0.60f, 0.10f, 0.0f, 0.0f, 0.0f, 0.60f);
break;
// --- разрушаемая обстановка -------------------------------------------
// Всё боевое по нулям: ни скорости, ни агро, ни урона. Даже если бы
// UpdateEnemies до них дошёл (он не доходит — см. IsProp), ящику нечем
// ни идти, ни бить. Единственное, что у них есть, — HP и радиус.
//
// Сопротивление отбросу единица: ящик от попадания не ездит по полу.
// Живучесть подобрана от ОДНОГО магазина: труху сносит очередью на ходу,
// шкафчик требует остановиться — и в этом вся разница между ними.
case EnemyKind::CRATE:
d.name = "CRATE";
d.hp = 60.0f; d.radius = 0.38f; d.knockResist = 1.0f;
d.r = 104; d.g = 76; d.b = 46;
break;
case EnemyKind::BARREL:
d.name = "BARREL";
d.hp = 95.0f; d.radius = 0.33f; d.knockResist = 1.0f;
d.r = 92; d.g = 84; d.b = 58;
break;
case EnemyKind::LOCKER:
d.name = "LOCKER";
d.hp = 140.0f; d.radius = 0.36f; d.knockResist = 1.0f;
d.r = 78; d.g = 88; d.b = 96;
break;
// Мишень приёмки: всё по нулям, поведения нет. Вид атаки ей выставлен
// формально — с нулевым уроном и нулевой дальностью он не сработает
// никогда, а UpdateEnemies до неё вообще не доходит.
default:
d.name = "TARGET";
d.hp = g_tune.targetHP;
d.radius = g_tune.targetRadius;
d.attack = AttackKind::SWIPE;
d.r = 120; d.g = 44; d.b = 44;
break;
}
return d;
}
const char* EnemyName(EnemyKind kind) { return EnemyDefOf(kind).name; }
void MakeTarget(ecs::World& world, EnemyKind kind, Vec2 pos, float knockResist,
const Tilemap* map)
{
const EnemyDef def = EnemyDefOf(kind);
Target t;
t.self = world.Create();
t.kind = kind;
t.pos = pos;
t.home = t.pos;
t.vel = {};
t.hp = def.hp;
t.maxHp = def.hp;
t.radius = def.radius;
t.resist = def.resist;
t.respawnTimer = 0.0f;
t.alive = true;
// У мишени приёмки сопротивление отбросу расписано по слотам, у твари оно —
// свойство вида. Отрицательное значение означает «взять из каталога».
t.knockResist = (knockResist >= 0.0f) ? knockResist : def.knockResist;
t.speed = def.speed;
t.aggroRadius = def.aggroRadius;
t.attackRange = def.attackRange;
t.damage = def.damage;
t.windup = def.windup;
t.interval = def.interval;
t.ranged = def.ranged;
t.projectileSpeed = def.projectileSpeed;
t.attack = def.attack;
t.swipeArcDeg = def.swipeArcDeg;
t.lungeDist = def.lungeDist;
t.lungeTime = def.lungeTime;
t.spitRange = def.spitRange;
t.slamRadius = def.slamRadius;
t.grabTime = def.grabTime;
t.grabTick = def.grabTick;
// Точки расставлены руками, а планировка генерируется: тварь, поставленная
// в стену, никогда бы оттуда не вышла — она сдвигается только когда идёт
// за кем-то, а из стены ей никого не видно.
if (map)
{
map->ResolveCircle(t.pos, t.radius);
t.home = t.pos;
}
world.Add<Target>(t.self, t);
}
void SpawnTargets(ecs::World& world, bool hostile, const Tilemap* map)
{
// Три полосы подряд в одном пуле: мишени, твари сверх них, обстановка.
// В песочнице приёмки живёт только первая — ни одной лишней сущности,
// и все замеры остаются сравнимыми с прежними.
const int total = hostile ? (NUM_TARGETS + NUM_EXTRA_ENEMIES + NUM_PROPS) : NUM_TARGETS;
for (int i = 0; i < total; ++i)
{
const int extraIdx = i - NUM_TARGETS;
const int propIdx = extraIdx - NUM_EXTRA_ENEMIES;
const bool extra = (extraIdx >= 0 && propIdx < 0);
const bool prop = (propIdx >= 0);
const EnemyKind kind = !hostile ? EnemyKind::DUMMY
: prop ? kPropKinds[propIdx]
: extra ? kExtraKinds[extraIdx]
: kTargetKinds[i];
const Vec2 pos = prop ? Vec2{kPropSpots[propIdx][0], kPropSpots[propIdx][1]}
: extra ? Vec2{kExtraSpots[extraIdx][0], kExtraSpots[extraIdx][1]}
: Vec2{kTargetSpots[i][0], kTargetSpots[i][1]};
MakeTarget(world, kind, pos, hostile ? -1.0f : kTargetKnockResist[i], map);
}
}
void KnockTarget(Target& t, Vec2 dir, float speed)
{
// Обстановка не ездит по полу от попаданий. Домой её никто не тянет (это
// свойство мишени приёмки), так что сдвинутый ящик уехал бы навсегда.
if (t.IsProp()) return;
t.vel += dir * (speed * t.KnockScale());
}
void UpdateTargets(std::vector<Target>& targets, float dt)
{
// Отдача гасится и мишень возвращается на своё место: расстановка уровня
// захардкожена, и «уползание» мишеней ломало бы все замеры разом.
const float damp = std::exp(-g_tune.targetKnockDamp * dt);
const float pull = 1.0f - std::exp(-g_tune.targetReturnRate * dt);
for (Target& t : targets)
{
t.hurtTimer = std::max(0.0f, t.hurtTimer - dt);
if (t.IsDummy())
{
// Мишень приёмки: ручки tuning продолжают крутить именно её.
t.radius = g_tune.targetRadius;
t.maxHp = g_tune.targetHP;
}
t.pos += t.vel * dt;
t.vel *= damp;
// Возврат на своё место — свойство МИШЕНИ, а не тела: тварь, которую
// тянет домой, не может ни догнать отряд, ни отойти от него.
if (t.IsDummy()) t.pos += (t.home - t.pos) * pull;
if (t.alive) continue;
// Разбитый хлам остаётся разбитым: RespawnDelay() возвращает ему
// отрицательный срок, и без этой проверки он воскресал бы в тот же кадр.
if (t.IsProp()) continue;
t.respawnTimer -= dt;
if (t.respawnTimer <= 0.0f)
{
const EnemyDef def = EnemyDefOf(t.kind);
t.alive = true;
t.hp = t.IsDummy() ? g_tune.targetHP : def.hp;
t.pos = t.home;
t.vel = {};
t.victim = -1;
t.aggro = false;
t.attackTimer = 0.0f;
t.windupTimer = 0.0f;
// Недоведённый рывок и незакрытый захват пережили бы смерть и
// вернулись бы вместе с тварью. Захваченного бойца при этом никто
// не держит: его таймер гаснет сам, пока держать некому.
t.lungeTimer = 0.0f;
t.lungeMask = 0;
t.grabbed = -1;
t.grabTimer = 0.0f;
t.grabTickTimer = 0.0f;
}
}
}
int CountAliveTargets(const std::vector<Target>& targets)
{
// Хлам здесь не считается: это знаменатель «по скольким целям отряд может
// работать» (спек 6.5) и цифра в HUD. Нетронутый ящик в углу не делает
// огневую позицию хуже и зачистку — незаконченной.
int n = 0;
for (const Target& t : targets)
if (t.alive && !t.IsProp()) ++n;
return n;
}