squad-proto/src/sim/squad.cpp

322 lines
15 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
#include "sim/squad.h"
#include "ai/lane_registry.h"
namespace
{
// Локальные слоты строя: x = вперёд, y = вправо. Множатся на formationSpacing.
const float kWedge[NUM_AGENTS][2] = {
{ 1.0f, 0.0f},
{ 0.0f, -0.9f},
{ 0.0f, 0.9f},
{-1.0f, -1.8f},
{-1.0f, 1.8f},
};
const float kLine[NUM_AGENTS][2] = {
{0.0f, -2.0f},
{0.0f, -1.0f},
{0.0f, 0.0f},
{0.0f, 1.0f},
{0.0f, 2.0f},
};
} // namespace
const char* FormationName(Formation f)
{
switch (f)
{
case Formation::WEDGE: return "WEDGE";
case Formation::LINE: return "LINE";
case Formation::RING: return "RING";
default: return "?";
}
}
void Squad::Init(ecs::World& world, Vec2 at)
{
anchor = at;
anchorPrev = at;
anchorVel = {};
forward = {1.0f, 0.0f};
state = AgentState::HOLDING;
idleTimer = g_tune.holdDelay;
holdElapsed = 0.0f;
urgency = 0.0f;
sprinting = false;
formSpacing = 1.0f;
formStretch = 1.0f;
meleeCount = std::min(std::max(meleeCount, 0), NUM_AGENTS);
pistolCount = std::min(std::max(pistolCount, 0), NUM_AGENTS - meleeCount);
// Сначала создаём все сущности бойцов, и только потом берём указатель на
// плотный массив пула: до конца заполнения он может переехать.
ecs::Entity ids[NUM_AGENTS];
for (int i = 0; i < NUM_AGENTS; ++i)
{
ids[i] = world.Create();
world.Add<Agent>(ids[i], Agent{});
}
agents.p = world.All<Agent>().data();
agents.n = NUM_AGENTS;
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a = Agent{};
a.self = ids[i];
a.slotIndex = i;
// Клинки занимают первые слоты: в клине это остриё и левое крыло,
// то есть ровно те, кто и так упирается в противника первым.
// Пистолеты — последние слоты: короткий ствол уезжает в хвост клина.
const bool isMelee = (i < meleeCount);
const bool isPistol = !isMelee && (i >= NUM_AGENTS - pistolCount);
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
// Стартовое оружие — то же, что и было: клинок, карабин, пистолет.
// Снаряжения на бойце НЕТ: броня меняет скорость, а на скорости меряются
// критерии приёмки. Комплекты раздаёт игровой режим (Game::EquipKits).
const ItemId weapon = isMelee ? ItemId::SABER
: isPistol ? ItemId::PISTOL
: ItemId::RIFLE;
a.weaponEntity = SpawnWeapon(world, weapon);
world.Add<Inventory>(ids[i], Inventory{weapon, ItemId::NONE, ItemId::NONE});
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
a.melee.sweepSign = (i & 1) ? -1.0f : 1.0f;
a.pos = SlotWorld(i);
a.prevPos = a.pos;
a.postPos = a.pos;
a.facing = 0.0f;
a.state = state;
a.currentTargetId = -1;
a.commitTimer = 0.0f;
// Фазы решателя разведены: за кадр решает максимум один агент (6.5).
a.solveTimer = g_tune.solveInterval * (float(i) / float(NUM_AGENTS));
SyncLoadout(world, a);
a.gun.ammo = a.gun.def.magazine; // выходим с полным магазином
Отряд растёт с одного оперативника до пяти (M24) Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка. Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 — самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое впечатление, и продать второго бойца за одну вылазку значило бы выбросить весь хоррор начала: вдвоём уже не страшно. NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ, а не размер отряда: делать её динамической это сотня мест ради числа, которое всё равно упирается в пять. Занятость слота это Agent::present, и он отличается от alive. Правила боя читают только alive — для них пустой слот и павший одно, и это верно. Разница нужна там, где показывают человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого слота нет ни того, ни другого. Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а главное — переживает ли одиночка первую схватку: отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2 Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится рядом с тварью. Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию формата, ни улучшения. Купленный запас патронов действовал до конца запуска и молча пропадал при чтении, а профиль без версии читался как формат 1. Круговой прогон этого не видел, потому что сверял три поля из десяти и делал это на пустом профиле, где всё сходится само. Теперь он сверяет поля списком и на набитом профиле. Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и получает пятерых при миграции — отобрать их значило бы съесть отряд, которого игрок не терял. --accept PASS без изменений: без профиля отряд по-прежнему впятером. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:29:19 +07:00
// Незанятый слот: сущность есть (по ней ходят все циклы), человека нет.
// Не alive — значит, ни решатель, ни твари, ни правила огня его не
// видят; не present — значит, его не видит и игрок.
a.present = (i < roster);
a.alive = a.present;
a.hp = a.alive ? a.maxHp : 0.0f;
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
}
}
Vec2 Squad::SlotLocal(int i) const
{
// Интервал и вытяжение — это ТЕКУЩЕЕ состояние строя, а не константа:
// на стоянке отряд собирается, в беге растягивается вдоль движения.
const float s = g_tune.formationSpacing * formSpacing;
const float fwd = s * formStretch;
switch (formation)
{
case Formation::LINE:
return {kLine[i][0] * fwd, kLine[i][1] * s};
case Formation::RING:
{
const float ang = float(i) * (TWO_PI_F / float(NUM_AGENTS));
const Vec2 d = FromAngle(ang) * 1.5f;
return {d.x * fwd, d.y * s};
}
case Formation::WEDGE:
default:
return {kWedge[i][0] * fwd, kWedge[i][1] * s};
}
}
Vec2 Squad::SlotWorld(int i) const
{
const Vec2 local = SlotLocal(i);
const Vec2 right = Perp(forward);
return anchor + forward * local.x + right * local.y;
}
Vec2 Squad::GoalFor(int i) const
{
// Боец ближнего боя ведёт себя одинаково в обоих состояниях: пока цель
// внутри его круга, он идёт к ней, а не в слот. Иначе клинок оказывался бы
// полезен только на стоянке — а он нужен как раз в движении.
// Но в спринте цель отменяется: бежим, а не деремся.
if (agents[i].IsMelee() && !sprinting) return agents[i].postPos;
if (agents[i].IsMelee()) return SlotWorld(i);
// В движении — слот строя, на месте — выбранная решателем огневая позиция.
return (state == AgentState::ADVANCING) ? SlotWorld(i) : agents[i].postPos;
}
Vec2 Squad::Centroid() const
{
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
// Центр масс считается по ЖИВЫМ: поводок якоря не должен тянуть отряд
// к телу павшего.
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
Vec2 c{};
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
int n = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
if (!agents[i].alive) continue;
c += agents[i].pos;
++n;
}
return (n > 0) ? c / float(n) : anchor;
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
}
void Squad::UpdateAnchorAndState(Vec2 moveWorldDir, bool sprint, const Tilemap& map, float dt)
{
anchorPrev = anchor;
const bool moving = LengthSq(moveWorldDir) > 1e-6f;
const AgentState prevState = state;
const Vec2 c = Centroid();
// --- натяжение поводка ----------------------------------------------------
// Поводок УПРУГИЙ, а не жёсткий. За мягкой границей курсор продолжает
// уходить, но идёт всё тяжелее, и до жёсткого предела не доходит никогда.
//
// Жёсткий поводок здесь не годился принципиально: он мгновенно упирается,
// дистанция намертво встаёт в одно значение, и по ней уже нельзя отличить
// «подправил направление» от «тяну отряд вперёд изо всех сил». Упругий
// поводок превращает дистанцию в непрерывную величину: чем сильнее тянешь,
// тем дальше уезжает курсор и тем быстрее бегут за ним.
const float soft = std::max(0.2f, g_tune.anchorLeash);
const float hard = std::max(soft + 0.2f, g_tune.anchorLeashMax);
const float start = soft * Clampf(g_tune.urgeStartFrac, 0.0f, 0.95f);
const float dist = Distance(anchor, c);
float pull = 0.0f;
if (moving) pull = Clampf((dist - start) / (hard - start), 0.0f, 1.0f);
if (moving && sprint) pull = 1.0f; // Shift — приказ бежать, без уговоров
const float rate = (pull > urgency) ? g_tune.urgeAttack : g_tune.urgeRelease;
urgency += (pull - urgency) * (1.0f - std::exp(-std::max(0.01f, rate) * dt));
urgency = Clampf(urgency, 0.0f, 1.0f);
// Бег без стрельбы — либо по прямому приказу, либо когда курсор реально
// уехал: отряд догоняет и ему не до целей.
sprinting = (moving && sprint) || (urgency >= g_tune.sprintAt);
// --- сам курсор -----------------------------------------------------------
// У него есть разгон и торможение: мгновенная смена скорости — это и есть
// «топорно». Отпустил клавиши — курсор доезжает, а не втыкается в грунт.
const float over = Clampf((dist - soft) / (hard - soft), 0.0f, 1.0f);
const float dragK = std::max(0.08f, 1.0f - Clampf(g_tune.leashDrag, 0.0f, 1.0f) * over);
const float speed = (sprint ? g_tune.anchorSpeedFast : g_tune.anchorSpeed) * dragK;
const Vec2 want = moving ? Normalized(moveWorldDir, forward) * speed : Vec2{};
const Vec2 dv = want - anchorVel;
anchorVel += ClampLength(dv, std::max(0.1f, g_tune.anchorAccel) * dt);
anchor += anchorVel * dt;
if (moving) forward = Normalized(moveWorldDir, forward);
// Жёсткий предел. Сопротивление обычно не даёт до него дойти, но отряд может
// и застрять в стене — тогда курсор без предела уехал бы за полкарты.
const Vec2 d = anchor - c;
const float len = Length(d);
if (len > hard && len > 1e-5f)
{
const Vec2 n = d / len;
anchor = c + n * hard;
const float vn = Dot(anchorVel, n);
if (vn > 0.0f) anchorVel -= n * vn;
}
map.ResolveCircle(anchor, g_tune.anchorRadius);
// --- состояние отряда ------------------------------------------------------
if (moving)
{
idleTimer = 0.0f;
state = AgentState::ADVANCING;
holdElapsed = 0.0f;
}
else
{
idleTimer += dt;
if (idleTimer >= g_tune.holdDelay) state = AgentState::HOLDING;
if (state == AgentState::HOLDING) holdElapsed += dt;
}
// --- дыхание строя ---------------------------------------------------------
const float wantSpacing = (state == AgentState::HOLDING)
? g_tune.holdSpacing
: Lerpf(1.0f, g_tune.sprintSpacing, urgency);
const float wantStretch = Lerpf(1.0f, g_tune.sprintStretch, urgency);
const float k = 1.0f - std::exp(-std::max(0.01f, g_tune.formMorphRate) * dt);
formSpacing += (wantSpacing - formSpacing) * k;
formStretch += (wantStretch - formStretch) * k;
if (state != prevState)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a.commitTimer = 0.0f;
// Встали — остаёмся на месте, пока решатель не найдёт лучше (6.5).
if (state == AgentState::HOLDING) a.postPos = a.pos;
}
if (state == AgentState::HOLDING) holdElapsed = 0.0f;
}
for (int i = 0; i < NUM_AGENTS; ++i) agents[i].state = state;
}
void Squad::UpdateMovement(const Tilemap& map, const LaneRegistry* lanes,
const std::vector<Target>& targets, float dt)
{
// Скорость — не тумблер «шаг/рывок», а непрерывная величина от натяжения.
const float baseSpeed = MoveSpeed();
for (int i = 0; i < NUM_AGENTS; ++i)
{
Agent& a = agents[i];
a.prevPos = a.pos;
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
if (!a.alive) { a.vel = {}; continue; } // упавший лежит там, где упал
// Захваченного не двигает НИЧТО: ни строй, ни решатель, ни расступание
// из чужой линии. В этом вся цена захвата — боец выпал из геометрии
// отряда и стоит там, где его взяли, пока тварь не отпустит или не умрёт.
// Оружие при этом работает: захват держит ноги, а не руки.
if (a.Grabbed()) { a.vel = {}; continue; }
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
const Vec2 goal = GoalFor(i);
const bool lunging = a.IsMelee() && a.melee.state == MeleeState::STRIKE;
// --- рывок клинка --------------------------------------------------
// В активном окне удара ось уже зафиксирована, и боец едет по ней.
// Seek/separation/расступание тут молчат: удар — это обязательство,
// а не предложение. Стены остаются: сквозь них не рвётся никто.
if (lunging)
{
const float speed = g_tune.meleeLungeSpeed;
const Vec2 dir = FromAngle(a.melee.dir);
Vec2 desired = dir * speed;
desired += WallAvoidForce(a, map, dir, speed) * g_tune.wWallAvoid;
IntegrateAgent(a, desired, speed, dt, map, g_tune.meleeLungeAccel);
continue;
}
// --- обычные слои, порядок 6.7 --------------------------------------
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
// speedMul — это снаряжение: плита тормозит, шпоры разгоняют. Без
// снаряжения он равен 1, и движение остаётся прежним до последнего бита.
const float maxSpeed = baseSpeed * a.SpeedScale() * a.speedMul;
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
Vec2 desired = SeekForce(a, goal, maxSpeed) * g_tune.wSeek;
desired += SeparationForce(a, agents.data(), NUM_AGENTS, maxSpeed) * g_tune.wSeparation;
// Замах — это обязательство. Выдёргивать бойца из чужой линии огня
// посреди него бессмысленно: стрелок всё равно не выстрелит (правило
// 6.3 запрещает), а удар сорвётся. Расступаться боец будет до и после.
if (lanes && !(a.IsMelee() && a.melee.Committed()))
desired += lanes->AvoidanceForce(i, a.pos, a.bodyRadius, maxSpeed) * g_tune.wLaneAvoid;
desired += TargetPushForce(a, targets, maxSpeed) * g_tune.wTargetPush;
desired += WallAvoidForce(a, map, Normalized(goal - a.pos), maxSpeed) * g_tune.wWallAvoid;
IntegrateAgent(a, desired, maxSpeed, dt, map);
}
}