СТИХИИ (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>
367 lines
19 KiB
C++
367 lines
19 KiB
C++
#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;
|
||
}
|