squad-proto/src/debug/overlay.cpp
z.kirill 7d6266aaf3 Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20)
Десять вех одним состоянием: разделять их пофайлово нечем, потому что
src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений,
все проверки проходят.

ДВИЖОК (engine/, цель tile2d)

  Каталог свой, и решение «это движок» принимается тем, куда положен файл.
  Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра
  и карты уехали в engine/config.h, потолок населения этажа стал своим
  (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала
  центром карты вместо позиции отряда.

  engine/projection.*  единственное место, где движок знает про изометрию;
                       видов сразу два — абстракция с одной реализацией ничем
                       не проверена и обычно оказывается неправильной
  engine/project.*     манифест проекта: пути, вид, кривая глубины
  engine/app.*         окно, кадр, fixed timestep — вместо двух копий цикла
  engine/tilemap_draw  движок научился показывать свою карту

РЕДАКТОР (editor/, цель tile2d_editor)

  Открывает любой проект и ни одной игры по имени не знает.
  --tool  одиннадцать безоконных команд: комнаты, каталог, кривая, сборка
          этажа, ASCII-карта, проверка набора. Правку делают файлами,
          последствия читают командой (docs/19-agent.md)
  терминал снизу: те же инструменты, git, сборка и запросы к claude/codex,
          открытый проект уходит потомкам через TILE2D_PROJECT

ПРИМЕР (samples/walk, цель tile2d_walk)

  Второй потребитель движка. Игра и редактор самостоятельность движка
  проверить не могут: редактор мира не рисует, а игра — то место, куда общий
  код и уезжает незаметно.

ИГРА (src/)

  Экстракшен: вес и два порога переноски, конечный боезапас, расходники
  применяются сами, общая сумка отряда, трата опыта на улучшения.

  Каждое из правил проверяется прогоном, а не глазами, и проверки поймали:
  вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче);
  боец с клинком не тратил найденные патроны при запасном стволе в сумке;
  массив виджетов экрана переполнился и молча съел хвост схрона; проверка
  улучшений сама ломала забег, вызывая NewRun посреди спуска.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 21:07:19 +03:00

376 lines
17 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
{
// -----------------------------------------------------------------------------
// Сетка оверлея.
//
// Кадр 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});
}