Тактический отряд с автоматическим огнём в изометрии, собственный софтверный растеризатор (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>
308 lines
13 KiB
C++
308 lines
13 KiB
C++
#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});
|
||
}
|