#include "debug/overlay.h" #include #include "game.h" #include "render/view.h" namespace { // ----------------------------------------------------------------------------- // Сетка оверлея. // // Кадр 480x270 растянут целым множителем и центрирован в окне, поэтому все // размеры ниже заданы в пикселях КАДРА и переводятся в окно через Layout. // Считать напрямую в пикселях окна нельзя: разметка сразу разъезжается // с картинкой, потому что множитель зависит от монитора. // ----------------------------------------------------------------------------- struct Layout { float x = 0.0f, y = 0.0f; // левый верхний угол кадра в окне float u = 1.0f; // размер пикселя кадра в пикселях окна int fw = 0, fh = 0; // размер кадра в его собственных пикселях Vector2 At(float fx, float fy) const { return Vector2{x + fx * u, y + fy * u}; } float Px(float frame) const { return frame * u; } }; Layout Of(const ScreenRect& fit) { const int s = (fit.scale > 0) ? fit.scale : 1; Layout l; l.x = float(fit.x); l.y = float(fit.y); l.u = float(s); l.fw = fit.w / s; l.fh = fit.h / s; return l; } // Радиусы и толщины — в пикселях кадра. На апскейле x3 они дают ту же картинку, // что была у прежних «магических» значений в пикселях окна. constexpr float R_ANCHOR = 1.5f; // точка якоря constexpr float R_SLOT = 2.0f; // слот строя constexpr float R_POST = 1.5f; // выбранная огневая позиция constexpr float R_CAND = 1.0f; // кандидат решателя constexpr float R_CAND_ALL = 0.7f; // отбракованный кандидат constexpr float R_BEST = 3.0f; // выбранный кандидат constexpr float R_TARGET = 4.0f; // назначенная цель constexpr float LINE_THIN = 0.35f; constexpr float LINE_BOLD = 0.7f; constexpr int LABEL_SIZE = 4; // подпись над бойцом constexpr float LABEL_DX = -6.0f; constexpr float LABEL_DY = -21.0f; constexpr float PANEL_X = 3.0f; // текстовая панель в левом верхнем углу constexpr float PANEL_Y = 3.0f; constexpr float PANEL_STEP = 7.0f; constexpr int PANEL_SIZE = 6; constexpr int HINT_SIZE = 5; // строка подсказки constexpr float HINT_BOTTOM = 8.0f; // отступ от нижней кромки КАДРА void Text(const Layout& l, const char* s, float fx, float fy, int frameSize, Color c) { DrawText(s, int(l.x + fx * l.u), int(l.y + fy * l.u), int(float(frameSize) * l.u), c); } // Мир -> окно: изометрия даёт пиксели кадра, Layout доводит их до окна. inline Vector2 ToWindow(const Layout& l, const View& v, Vec2 world) { const Vec2 s = v2d::WorldToScreen(world); return l.At(s.x + float(v.scene.ox), s.y + float(v.scene.oy)); } void Ring(const Layout& l, 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(l, 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 ScreenRect& fit) const { if (!show) return; const Layout l = Of(fit); const Squad& squad = game.squad; // --- якорь, круги когезии, слоты строя --- Ring(l, view, squad.anchor, g_tune.cohesionRadius, Color{60, 90, 120, 160}); // Свой, больший круг бойцов ближнего боя — их отпускают дальше. if (squad.meleeCount > 0) Ring(l, view, squad.anchor, g_tune.meleeCohesionRadius, Color{150, 100, 60, 110}); // Поводок курсора, обе границы упругой модели: сплошной круг — мягкая // граница (дальше курсор тяжелеет), тусклый внешний — жёсткий предел. // По зазору между ними видно натяжение, то есть скоро ли отряд побежит. Ring(l, view, squad.Centroid(), g_tune.anchorLeash, squad.sprinting ? Color{255, 160, 70, 130} : Color{70, 110, 150, 110}); Ring(l, view, squad.Centroid(), std::max(g_tune.anchorLeash + 0.2f, g_tune.anchorLeashMax), Color{90, 70, 110, 70}); DrawCircleV(ToWindow(l, view, squad.anchor), l.Px(R_ANCHOR), squad.sprinting ? Color{255, 190, 120, 230} : Color{120, 200, 255, 220}); for (int i = 0; i < NUM_AGENTS; ++i) { const Vector2 p = ToWindow(l, view, squad.SlotWorld(i)); DrawCircleLinesV(p, l.Px(R_SLOT), 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(l, 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(l, view, a.pos), ToWindow(l, view, e), c); } if (m.state == MeleeState::STRIKE) { const Vec2 blade = a.pos + FromAngle(m.BladeAngleAt(m.Progress())) * m.reach; DrawLineEx(ToWindow(l, view, a.pos), ToWindow(l, view, blade), l.Px(LINE_BOLD), 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(l, view, a.MuzzleTowards(end)), ToWindow(l, view, end), l.Px(LINE_BOLD), 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(l, view, r.candidates[k]); if (!r.valid[k]) { DrawCircleV(p, l.Px(R_CAND_ALL), 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, l.Px(R_CAND), Color{v, v, 255, 200}); } if (r.bestIndex > 0) DrawCircleLinesV(ToWindow(l, view, r.candidates[r.bestIndex]), l.Px(R_BEST), Color{255, 255, 255, 230}); } } // --- диски обзора (F6) --- if (view.showSight) { for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; Ring(l, view, a.pos, g_tune.sightRadius, Color{60, 130, 90, 70}); Ring(l, 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(l, view, a.pos), ToWindow(l, view, e), Color{90, 200, 140, 110}); } } } // --- подписи над агентами --- for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = squad.agents[i]; const Vec2 s = v2d::WorldToScreen(a.pos); const float fx = s.x + float(view.scene.ox) + LABEL_DX; const float fy = s.y + float(view.scene.oy) + LABEL_DY; if (a.IsMelee()) Text(l, TextFormat("%dM %c t%d %s", i, a.state == AgentState::ADVANCING ? 'A' : 'H', a.currentTargetId, MeleePhase(a.melee.state)), fx, fy, LABEL_SIZE, 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()) Text(l, TextFormat("%d%s %c t%d RELOAD %.1f", i, tag, a.state == AgentState::ADVANCING ? 'A' : 'H', a.currentTargetId, double(a.gun.reloadTimer)), fx, fy, LABEL_SIZE, col); else Text(l, 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), fx, fy, LABEL_SIZE, col); } // выбранная огневая позиция if (a.state == AgentState::HOLDING || a.IsMelee()) DrawCircleLinesV(ToWindow(l, view, a.postPos), l.Px(R_POST), 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(l, view, from), ToWindow(l, view, to), l.Px(LINE_THIN), 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(l, view, from), ToWindow(l, view, e), l.Px(LINE_THIN), Color{255, 255, 255, 50}); } if (game.aim.assignedTargetId >= 0) DrawCircleLinesV(ToWindow(l, view, game.targets[game.aim.assignedTargetId].pos), l.Px(R_TARGET), Color{255, 120, 120, 230}); } // --- текстовая панель --- float ty = PANEL_Y; auto line = [&](const char* text, Color c = RAYWHITE) { Text(l, text, PANEL_X, ty, PANEL_SIZE, c); ty += PANEL_STEP; }; 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}); Text(l, "F1 overlay F2 lanes F3 cand F4 metrics F5 horror F6 sight R reset Esc", PANEL_X, float(l.fh) - HINT_BOTTOM, HINT_SIZE, Color{160, 160, 160, 200}); }