squad-proto/src/ai/melee.cpp

200 lines
9.3 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 "ai/melee.h"
namespace
{
constexpr float BIG_F = 1e18f;
// Угловой полуразмер тела радиуса r, видимого с расстояния dist.
// Вплотную союзник перекрывает почти весь сектор — так и должно быть.
float AngularHalfSize(float dist, float r)
{
return std::asin(Clampf(r / std::max(dist, r), 0.0f, 1.0f));
}
// Годится ли мишень как цель ближнего боя: жива, внутри СВОЕГО круга бойца,
// не дальше meleeSeekRadius от него самого и видна (за стену не бегаем —
// путей в прототипе нет, а обход угла даёт слой wallAvoidance).
bool MeleeCandidate(const Agent& a, const SolverContext& ctx, int id)
{
const Target& t = (*ctx.targets)[id];
if (!t.alive) return false;
if (Distance(t.pos, ctx.anchor) > g_tune.meleeCohesionRadius) return false;
if (Distance(t.pos, a.pos) > g_tune.meleeSeekRadius) return false;
if (ctx.map->RaycastBlocked(a.pos, t.pos)) return false;
return true;
}
} // namespace
void EvaluateMeleeTarget(Agent& a, int index, const SolverContext& ctx)
{
const std::vector<Target>& targets = *ctx.targets;
// Линии огня у бойца нет и быть не может — обнуляем всю стрелковую часть,
// иначе он попадёт и в реестр линий, и в FIRE UPTIME.
a.hasLane = false;
a.priorityLane = false;
a.laneIntent = false;
a.targetBlockedByAlly = false;
a.targetBlockedByWall = false;
MeleeWeapon& m = a.melee;
// Пока идёт замах или удар, цель не переназначается: ось уже зафиксирована.
if (m.Committed() && m.targetId >= 0 && targets[m.targetId].alive)
{
a.currentTargetId = m.targetId;
a.laneEnd = targets[m.targetId].pos;
m.inReach = Distance(a.pos, targets[m.targetId].pos) <=
m.reach + targets[m.targetId].radius;
return;
}
m.targetId = -1;
m.inReach = false;
// Назначенная игроком цель приоритетна и здесь (6.8), но только если она
// проходит те же условия — иначе боец убежал бы через всю карту.
int best = -1;
if (ctx.assignedTargetId >= 0 && ctx.assignedTargetId < int(targets.size()) &&
MeleeCandidate(a, ctx, ctx.assignedTargetId))
{
best = ctx.assignedTargetId;
}
else
{
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
// Обстановка — только когда вокруг чисто. Предикат общий со стрелком
// (EnemyNearby): ящик за спиной не уводит с боя ни клинок, ни ствол.
const bool wantProps = !EnemyNearby(a.pos, ctx);
Прототип боевого ядра: отряд, хоррор-слой, ближний бой Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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
float bestCost = BIG_F;
for (int i = 0; i < int(targets.size()); ++i)
{
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 (targets[i].IsProp() != wantProps) 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
if (!MeleeCandidate(a, ctx, i)) continue;
// Цена = дистанция + штраф за каждого своего, уже вцепившегося
// в эту мишень. Ближняя цель всё ещё выигрывает, но не любой ценой:
// вдвоём на одну — это оборванные взмахи друг об друга и ноль урона.
float cost = Distance(a.pos, targets[i].pos);
for (int k = 0; k < ctx.agentCount; ++k)
{
if (k == index) continue;
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 (!ctx.agents[k].alive) 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
if (ctx.agents[k].IsMelee() && ctx.agents[k].melee.targetId == i)
cost += g_tune.meleeClaimPenalty;
}
if (cost >= bestCost) continue;
best = i;
bestCost = cost;
}
}
m.targetId = best;
a.currentTargetId = best;
if (best < 0) return;
a.laneEnd = targets[best].pos;
m.inReach = Distance(a.pos, targets[best].pos) <= m.reach + targets[best].radius;
m.arcBlockedByAlly = m.inReach && !MeleeArcClear(a, index, AngleOf(targets[best].pos - a.pos), ctx);
}
Vec2 MeleeGoal(const Agent& a, const SolverContext& ctx, Vec2 fallbackSlot)
{
const MeleeWeapon& m = a.melee;
if (m.targetId < 0) return fallbackSlot;
const Target& t = (*ctx.targets)[m.targetId];
// Встаём вплотную, но не внутрь: reach считается от центра тела, поэтому
// с такой дистанции удар достаёт с запасом.
const float stand = t.radius + a.bodyRadius + g_tune.meleeGap;
const Vec2 away = Normalized(a.pos - t.pos, FromAngle(a.facing + PI_F));
return t.pos + away * stand;
}
bool MeleeArcClear(const Agent& a, int index, float axisAngle, const SolverContext& ctx)
{
const MeleeWeapon& m = a.melee;
const float half = m.HalfArc();
const float reach = m.reach + g_tune.meleeAllyMargin;
// Взмах живёт windup + strike секунд, и всё это время союзник продолжает
// идти. Проверять только текущее положение — значит начинать удары, которые
// всё равно придётся обрывать. Это ровно та же поправка, что и
// AllyInterceptsShot для пули: правило не ослабляется, оно смотрит вперёд.
const float horizon = (m.windup + m.strike) * g_tune.meleeStartPredict;
for (int i = 0; i < ctx.agentCount; ++i)
{
if (i == index) continue;
const Agent& ally = ctx.agents[i];
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 (!ally.alive) 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 probe[2] = {ally.pos, ally.pos + ally.vel * horizon};
for (const Vec2& q : probe)
{
const Vec2 d = q - a.pos;
const float dist = Length(d);
if (dist > reach + ally.bodyRadius) continue;
if (dist < 1e-4f) return false; // стоим в одной точке — бить нельзя
const float pad = AngularHalfSize(dist, ally.bodyRadius + g_tune.meleeAllyMargin);
if (std::fabs(AngleDiff(axisAngle, AngleOf(d))) <= half + pad) return false;
}
}
return true;
}
bool BladeSweepHits(Vec2 from, float angPrev, float angCur, float bladeHalf, float reach,
Vec2 p, float pRadius)
{
const Vec2 d = p - from;
const float dist = Length(d);
if (dist > reach + pRadius) return false;
// Тела перекрываются — попадает любой взмах, и это честно: цель обнимает
// рукоять. Случай редкий, отсечён отдельно, чтобы ниже не делить на ноль.
if (dist <= pRadius) return true;
// Дальше — ТОЧНАЯ геометрия: лезвие это ОТРЕЗОК от бойца длиной reach,
// повернувшийся за шаг с angPrev на angCur.
//
// Раньше здесь стоял угловой допуск asin(r/dist), и он вырождался: на
// контактной дистанции (dist -> pRadius) asin даёт 90 градусов, «сектор»
// разрастался в полуплоскость, и боец рубил за спину. В фазе рывка он как
// раз проезжает вплотную — отсюда и брались удары «на 360 градусов».
// Отрезок такого не допускает: для цели позади ближайшая точка лезвия —
// рукоять, а до неё ровно dist, что больше pRadius.
const float ang = AngleOf(d);
const float sweep = AngleDiff(angPrev, angCur); // короткая дельта за шаг
const float rel = AngleDiff(angPrev, ang); // где цель относительно старта
// Направление на цель попало в заметённую за шаг полосу — значит лезвие
// прошло через неё (дистанцию уже проверили сверху).
const float lo = std::min(0.0f, sweep) - bladeHalf;
const float hi = std::max(0.0f, sweep) + bladeHalf;
if (rel >= lo && rel <= hi) return true;
// Иначе цель могла быть задета краем: проверяем оба крайних положения.
const float ends[2] = {angPrev, angCur};
for (float a : ends)
if (DistancePointToSegment(p, from, from + FromAngle(a) * reach) <= pRadius) return true;
return false;
}
Vec2 MeleeStrikeEnd(const Agent& a, const SolverContext& ctx)
{
const MeleeWeapon& m = a.melee;
// На замахе и ударе ось уже зафиксирована, до него — направление на цель.
float axis = m.dir;
if (!m.Committed())
{
if (m.targetId < 0) return a.pos;
axis = AngleOf((*ctx.targets)[m.targetId].pos - a.pos);
}
return a.pos + FromAngle(axis) * m.reach;
}