squad-proto/src/debug/overlay.cpp

308 lines
13 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 "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});
}