Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (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;
|
Триггеры: четыре условных эффекта на аффиксах (M28)
ON_HIT поджигает цель, ON_KILL даёт рывок скорости стаками, ON_HURT
обжигает того, кто ударил, ON_DOWN даёт отряду ярость за павшего.
Отдельным тегом аффикса, то есть по тем же правилам, что и всё
остальное: платит по тиру, влезает в бюджет, катается и прививается.
СДЕЛАНО НЕ ТАК, КАК ПРЕДЛАГАЛ ДИЗАЙН. В docs/21-damage.md §4 стояло:
журнал боевых событий уже есть, вешайте триггеры на него. Звучит
правильно, и сделать так нельзя: sim/events.h это канал «симуляция ->
эффекты», односторонний по устройству, и в headless-прогонах его нет
вовсе. Начни правила боя его читать — игра станет вести себя по-разному
с окном и без, то есть --accept начнёт мерить не то, что меряет.
Триггеры срабатывают в месте урона. Опасение «два канала разойдутся»
снято иначе: второго канала никто и не завёл, есть четыре точки рядом с
тем уроном, который триггер обслуживает.
Четыре из девяти, и это не «пока»: каждый эффект обязан класть своё
число в уже существующий канал влияния. Крита в игре нет, площадного
урона нет, выдачи предметов на входе нет — заводить их ради строк
таблицы значит завести пять мёртвых веток. Причины выписаны в документе.
Три ограничителя проверяются числами в --descend:
триггеры: аффикс доехал да, ON_HIT поджёг стаков 1, откат 0.9 с
(за него наложено 0), ON_KILL стаков 1, скорость 1.06
рекурсия: за один шаг стаков прибавлялось максимум 1
Первая строка про проводку: аффикс с триггером легко сделать так, что он
лежит в предмете, показывается в подсказке и не делает ничего.
Проверка сперва брала targets[0] и проходила на descent по везению: на
hollow первым в пуле стоял ящик, а обстановка не горит и убийством не
считается. Теперь цель ищется по виду и ставится в свободную клетку с
прямой видимостью — иначе замеряется планировка этажа, а не триггеры.
Шаг симуляции 3.7 мкс из 16666. --accept PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 07:32:12 +07:00
|
|
|
|
a.trig.rng = 1u + uint32_t(i) * 2654435761u; // свой поток на слот
|
Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (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.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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|