#include "debug/overlay.h" #include #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}); }