squad-proto/src/debug/overlay.cpp
z.kirill 7880f275bd Прототип боевого ядра: отряд, хоррор-слой, ближний бой
Тактический отряд с автоматическим огнём в изометрии, собственный
софтверный растеризатор (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 13:00:49 +03:00

308 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "debug/overlay.h"
#include <raylib.h>
#include "game.h"
#include "render/view.h"
namespace
{
// Мир -> координаты окна (кадр уже растянут в WINDOW_SCALE раз).
inline Vector2 ToWindow(const View& v, Vec2 world)
{
const Vec2 s = iso::WorldToScreen(world);
return Vector2{(s.x + float(v.scene.ox)) * float(WINDOW_SCALE),
(s.y + float(v.scene.oy)) * float(WINDOW_SCALE)};
}
void Ring(const View& g, Vec2 center, float radiusWorld, Color c, int segments = 48)
{
Vector2 prev{};
for (int i = 0; i <= segments; ++i)
{
const float a = float(i) * (TWO_PI_F / float(segments));
const Vector2 p = ToWindow(g, center + FromAngle(a) * radiusWorld);
if (i > 0) DrawLineV(prev, p, c);
prev = p;
}
}
const char* StateName(AgentState s)
{
return (s == AgentState::ADVANCING) ? "ADVANCING" : "HOLDING";
}
const char* MeleePhase(MeleeState s)
{
switch (s)
{
case MeleeState::WINDUP: return "wind";
case MeleeState::STRIKE: return "HIT";
case MeleeState::RECOVER: return "rec";
default: return "-";
}
}
Color ScaleAlpha(Color c, float k)
{
c.a = (unsigned char)Clampf(float(c.a) * k, 0.0f, 255.0f);
return c;
}
} // namespace
void Overlay::HandleKeys()
{
if (IsKeyPressed(KEY_F1)) show = !show;
if (IsKeyPressed(KEY_F2)) showLanes = !showLanes;
if (IsKeyPressed(KEY_F3)) showCandidates = !showCandidates;
if (IsKeyPressed(KEY_F4)) showMetrics = !showMetrics;
if (IsKeyPressed(KEY_LEFT_BRACKET)) tuneIndex = (tuneIndex + TUNABLE_COUNT - 1) % TUNABLE_COUNT;
if (IsKeyPressed(KEY_RIGHT_BRACKET)) tuneIndex = (tuneIndex + 1) % TUNABLE_COUNT;
const TunableParam& p = TUNABLES[tuneIndex];
float& value = g_tune.*(p.member);
if (IsKeyPressed(KEY_MINUS) || IsKeyPressedRepeat(KEY_MINUS))
value = Clampf(value - p.step, p.minV, p.maxV);
if (IsKeyPressed(KEY_EQUAL) || IsKeyPressedRepeat(KEY_EQUAL))
value = Clampf(value + p.step, p.minV, p.maxV);
}
void Overlay::Draw(const Game& game, const View& view) const
{
if (!show) return;
const Squad& squad = game.squad;
// --- якорь, круги когезии, слоты строя ---
Ring(view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160});
// Свой, больший круг бойцов ближнего боя — их отпускают дальше.
if (squad.meleeCount > 0)
Ring(view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110});
// Поводок курсора, обе границы упругой модели: сплошной круг — мягкая
// граница (дальше курсор тяжелеет), тусклый внешний — жёсткий предел.
// По зазору между ними видно натяжение, то есть скоро ли отряд побежит.
Ring(view, squad.Centroid(), g_tune.anchorLeash,
squad.sprinting ? Color{255, 160, 70, 130} : Color{70, 110, 150, 110});
Ring(view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax),
Color{90, 70, 110, 70});
DrawCircleV(ToWindow(view, squad.anchor), 4.0f,
squad.sprinting ? Color{255, 190, 120, 230} : Color{120, 200, 255, 220});
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Vector2 p = ToWindow(view, squad.SlotWorld(i));
DrawCircleLinesV(p, 5.0f, Color{80, 120, 160, 140});
}
// --- сектор ближнего боя ---
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (!a.IsMelee()) continue;
const MeleeWeapon& m = a.melee;
Color c;
switch (m.state)
{
case MeleeState::WINDUP: c = Color{255, 200, 80, 220}; break; // замах
case MeleeState::STRIKE: c = Color{255, 255, 255, 240}; break; // удар
case MeleeState::RECOVER: c = Color{120, 120, 140, 160}; break;
default:
// В покое цвет говорит, почему не бьём: жёлтый — свой в дуге.
c = m.arcBlockedByAlly ? Color{240, 210, 60, 180}
: Color{90, 150, 200, 110};
break;
}
Ring(view, a.pos, m.reach, ScaleAlpha(c, 0.55f), 24);
const float axis = m.Committed() ? m.dir : a.facing;
const float half = m.HalfArc();
for (int s = -1; s <= 1; s += 2)
{
const Vec2 e = a.pos + FromAngle(axis + half * float(s)) * m.reach;
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), c);
}
if (m.state == MeleeState::STRIKE)
{
const Vec2 blade = a.pos + FromAngle(m.BladeAngleAt(m.Progress())) * m.reach;
DrawLineEx(ToWindow(view, a.pos), ToWindow(view, blade), 2.0f, c);
}
}
// --- линии огня (F2) ---
if (showLanes)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
if (a.currentTargetId < 0 && !a.targetBlockedByAlly && !a.targetBlockedByWall) continue;
Color c;
if (a.hasLane) c = Color{60, 230, 90, 220}; // чистая, стреляет
else if (a.targetBlockedByWall) c = Color{230, 70, 60, 200}; // перекрыта стеной
else c = Color{240, 210, 60, 200}; // перекрыта своим
const Vec2 end = a.laneEnd;
DrawLineEx(ToWindow(view, a.MuzzleTowards(end)), ToWindow(view, end), 2.0f, c);
}
}
// --- кандидаты решателя (F3) ---
if (showCandidates)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
if (!game.lastSolveValid[i]) continue;
const SolveResult& r = game.lastSolve[i];
float lo = 1e9f, hi = -1e9f;
for (int k = 0; k < r.count; ++k)
{
if (!r.valid[k]) continue;
lo = std::min(lo, r.scores[k]);
hi = std::max(hi, r.scores[k]);
}
const float span = std::max(1e-3f, hi - lo);
for (int k = 0; k < r.count; ++k)
{
const Vector2 p = ToWindow(view, r.candidates[k]);
if (!r.valid[k])
{
DrawCircleV(p, 2.0f, Color{80, 40, 40, 140}); // отбракован
continue;
}
const float t = (r.scores[k] - lo) / span; // синий -> белый
const unsigned char v = (unsigned char)(60 + 195 * t);
DrawCircleV(p, 3.0f, Color{v, v, 255, 200});
}
if (r.bestIndex > 0)
DrawCircleLinesV(ToWindow(view, r.candidates[r.bestIndex]), 8.0f,
Color{255, 255, 255, 230});
}
}
// --- диски обзора (F6) ---
if (view.showSight)
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
Ring(view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70});
Ring(view, a.pos, g_tune.sightPersonalRadius, Color{90, 170, 120, 110});
// Границы конуса взгляда.
const float half = g_tune.sightConeDeg * 0.5f * DEG2RAD_F;
for (int sgn = -1; sgn <= 1; sgn += 2)
{
const Vec2 e = a.pos + FromAngle(a.facing + half * float(sgn)) * g_tune.sightRadius;
DrawLineV(ToWindow(view, a.pos), ToWindow(view, e), Color{90, 200, 140, 110});
}
}
}
// --- подписи над агентами ---
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = squad.agents[i];
const Vector2 p = ToWindow(view, a.pos);
if (a.IsMelee())
DrawText(TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, MeleePhase(a.melee.state)),
int(p.x) - 18, int(p.y) - 62, 12, Color{255, 210, 170, 210});
else
{
// Патроны и перезарядка — то, чего не видно по силуэту, но от чего
// напрямую зависит, почему стрелок с чистой линией молчит.
const char* tag = (a.gunId == FirearmId::PISTOL) ? "P" : "R";
const Color col = a.gun.Reloading() ? Color{255, 170, 90, 230}
: Color{220, 220, 220, 210};
if (a.gun.Reloading())
DrawText(TextFormat("%d%s %c t%d RELOAD %.1f", i, tag,
a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, double(a.gun.reloadTimer)),
int(p.x) - 18, int(p.y) - 62, 12, col);
else
DrawText(TextFormat("%d%s %c t%d %d/%d", i, tag,
a.state == AgentState::ADVANCING ? 'A' : 'H',
a.currentTargetId, a.gun.ammo, a.gun.def.magazine),
int(p.x) - 18, int(p.y) - 62, 12, col);
}
// выбранная огневая позиция
if (a.state == AgentState::HOLDING || a.IsMelee())
DrawCircleLinesV(ToWindow(view, a.postPos), 4.0f, Color{160, 160, 255, 140});
}
// --- назначенная цель / вектор прицела (6.8) ---
if (game.aim.manual)
{
const Vec2 from = squad.anchor;
const Vec2 to = from + FromAngle(game.aim.dir) * g_tune.weaponRange;
DrawLineEx(ToWindow(view, from), ToWindow(view, to), 1.0f, Color{255, 255, 255, 90});
const float cone = g_tune.aimConeDeg * DEG2RAD_F;
for (int s = -1; s <= 1; s += 2)
{
const Vec2 e = from + FromAngle(game.aim.dir + cone * float(s)) * g_tune.weaponRange;
DrawLineEx(ToWindow(view, from), ToWindow(view, e), 1.0f, Color{255, 255, 255, 50});
}
if (game.aim.assignedTargetId >= 0)
DrawCircleLinesV(ToWindow(view, game.targets[game.aim.assignedTargetId].pos), 12.0f,
Color{255, 120, 120, 230});
}
// --- текстовая панель ---
int y = 8;
auto line = [&](const char* text, Color c = RAYWHITE) {
DrawText(text, 8, y, 18, c);
y += 20;
};
line(TextFormat("%s %s aim:%s fps %d", StateName(squad.state),
FormationName(squad.formation), game.aim.manual ? "MANUAL" : "AUTO", GetFPS()));
// Натяжение поводка: главная новая величина управления. Видно, почему отряд
// ускорился и почему перестал стрелять.
line(TextFormat("PULL %.2f %s speed %.2f form %.2fx%.2f", double(squad.urgency),
squad.sprinting ? "SPRINT" : " ", double(squad.MoveSpeed()),
double(squad.formSpacing), double(squad.formStretch)),
squad.sprinting ? Color{255, 180, 90, 255} : Color{170, 200, 230, 255});
if (showMetrics)
{
const Metrics& m = game.metrics;
line(TextFormat("FRIENDLY_HITS %d", m.friendlyHits),
m.friendlyHits == 0 ? Color{120, 240, 120, 255} : Color{255, 90, 90, 255});
line(TextFormat("FIRE UPTIME %.0f%% [%.0f %.0f %.0f %.0f %.0f]", double(m.uptimeAvg),
double(m.uptimePct[0]), double(m.uptimePct[1]), double(m.uptimePct[2]),
double(m.uptimePct[3]), double(m.uptimePct[4])));
line(TextFormat("SETTLE %.2f s shots %d kills %d reloads %d", double(m.lastSettle),
m.shotsFired, m.targetsKilled, m.reloads));
if (squad.meleeCount > 0)
line(TextFormat("MELEE reach %.0f%% swings %d hits %d aborted %d",
double(m.meleeUptimeAvg), m.meleeSwings, m.meleeHits, m.meleeAborts),
m.meleeAborts == 0 ? Color{255, 200, 150, 255} : Color{240, 210, 60, 255});
line(TextFormat("sim %.2f render %.2f ms/frame", double(m.simMs), double(m.renderMs)),
Color{180, 180, 180, 255});
// Кровь: сколько тайлов пола уже запечено. Растёт и не убывает — это
// и есть «декали навсегда», и видно, во что они обходятся по памяти.
// Только ASCII: у встроенного шрифта raylib нет кириллицы, она вылезает
// вопросительными знаками. Весь оверлей по этой причине на латинице.
line(TextFormat("BLOOD %d splats, %d tiles baked (limit %d)", view.blood.splats,
int(view.blood.stained.size()), BLOOD_TILE_BUDGET),
Color{200, 130, 130, 255});
}
const TunableParam& p = TUNABLES[tuneIndex];
line(TextFormat("[ %s ] = %.3f ( [ ] select, - = adjust )", p.name,
double(g_tune.*(p.member))),
Color{255, 210, 120, 255});
DrawText("F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc", 8,
WINDOW_H - 24, 16, Color{160, 160, 160, 200});
}