squad-proto/src/render/hud.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

226 lines
11 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 "render/hud.h"
#include <cstdio>
#include "engine/microfont.h"
#include "game.h"
#include "render/sprites.h"
namespace
{
constexpr int CARD_W = 92;
constexpr int CARD_H = 22;
constexpr int CARD_GAP = 2;
constexpr int BAR_W = 52;
const uint32_t COL_FRAME = RGBA(24, 26, 32);
const uint32_t COL_EMPTY = RGBA(44, 48, 56);
const uint32_t COL_DEAD = RGBA(70, 26, 26);
// Рамка карточки: тёмная подложка, чтобы HUD читался поверх любого пола.
void Panel(Framebuffer& fb, int x, int y, int w, int h, uint32_t fill)
{
fb.FillRect(x, y, w, h, fill);
fb.Line(x, y, x + w - 1, y, COL_FRAME);
fb.Line(x, y + h - 1, x + w - 1, y + h - 1, COL_FRAME);
fb.Line(x, y, x, y + h - 1, COL_FRAME);
fb.Line(x + w - 1, y, x + w - 1, y + h - 1, COL_FRAME);
}
// Патроны: по квадратику на выстрел, пока их не больше BAR_W/2. Дальше —
// обычная полоса: сорок квадратиков автомата разглядывать всё равно никто
// не будет, а «сколько осталось» видно и так.
// Запас патронов СВЕРХ магазина — тонкая полоса под патронами.
//
// Это главный ресурс вылазки, и он обязан таять на глазах: игрок решает «ещё
// этаж или наверх» именно по нему. Пустой запас — красная риска на месте
// полосы: не «полоса нулевой длины», которую не отличить от её отсутствия.
void DrawReserve(Framebuffer& fb, int x, int y, const Agent& a)
{
if (a.ammoReserve < 0) return; // песочница: патроны бесконечны
const float k = Clampf(float(a.ammoReserve) / std::max(1.0f, g_tune.startAmmo), 0.0f, 1.0f);
fb.FillRect(x, y, BAR_W, 1, COL_EMPTY);
if (a.ammoReserve > 0)
fb.FillRect(x, y, std::max(1, int(float(BAR_W) * k)), 1,
(k < 0.2f) ? RGBA(220, 120, 60) : RGBA(150, 140, 110));
else
fb.FillRect(x + BAR_W / 2 - 2, y, 4, 1, RGBA(220, 70, 60));
}
void DrawAmmo(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col)
{
const int mag = a.gun.def.magazine;
if (a.gun.Reloading())
{
// Перезарядка: полоса набирается слева направо. Это ЕДИНСТВЕННЫЙ момент,
// когда стрелок беспомощен, и его надо видеть без чтения цифр.
const float k = 1.0f - Clampf(a.gun.reloadTimer / std::max(0.01f, a.gun.def.reloadTime),
0.0f, 1.0f);
fb.FillRect(x, y, BAR_W, 3, COL_EMPTY);
fb.FillRect(x, y, int(float(BAR_W) * k), 3, RGBA(210, 150, 60));
return;
}
if (mag > 0 && mag <= BAR_W / 2)
{
const int step = BAR_W / mag;
for (int i = 0; i < mag; ++i)
fb.FillRect(x + i * step, y, std::max(1, step - 1), 3,
(i < a.gun.ammo) ? col : COL_EMPTY);
return;
}
fb.FillRect(x, y, BAR_W, 3, COL_EMPTY);
fb.FillRect(x, y, int(float(BAR_W) * a.gun.AmmoFrac()), 3, col);
}
// Клинок: фаза удара. Замах, удар и восстановление — три разных цвета, потому
// что это три разных состояния бойца, а не одна «атака».
void DrawMeleeState(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col)
{
uint32_t c = COL_EMPTY;
float k = 1.0f;
switch (a.melee.state)
{
case MeleeState::WINDUP: c = RGBA(220, 180, 70); k = a.melee.Progress(); break;
case MeleeState::STRIKE: c = RGBA(250, 240, 220); k = 1.0f; break;
case MeleeState::RECOVER: c = RGBA(150, 70, 70); k = 1.0f - a.melee.Progress(); break;
default:
// Готов бить — полоса цвета бойца; иначе видно, сколько ждать.
c = (a.melee.cooldown <= 0.0f) ? col : COL_EMPTY;
k = (a.melee.cooldown <= 0.0f)
? 1.0f
: 1.0f - Clampf(a.melee.cooldown / std::max(0.01f, a.melee.interval),
0.0f, 1.0f);
break;
}
fb.FillRect(x, y, BAR_W, 3, COL_EMPTY);
fb.FillRect(x, y, int(float(BAR_W) * Clampf(k, 0.0f, 1.0f)), 3, c);
}
// -----------------------------------------------------------------------------
// Строка забега: этаж, отделка, опыт. Единственные БУКВЫ в HUD, и появились
// они по одной причине: номер этажа — это число, и показать его формой нельзя
// (engine/microfont.h). Всё остальное в HUD по-прежнему без единой буквы.
// -----------------------------------------------------------------------------
void DrawRunBar(Framebuffer& fb, const Game& game)
{
if (!game.levelGenerated) return; // на захардкоженной карте этажа нет
const uint32_t COL_TEXT = RGBA(206, 200, 190);
const uint32_t COL_SHADOW = RGBA(6, 6, 8);
const uint32_t COL_READY = RGBA(120, 226, 150); // вниз, глубже
const uint32_t COL_OUT = RGBA(130, 200, 240); // наверх, с добычей
char buf[48];
FloorLabel(game.level.floor, buf, sizeof(buf));
MicroTextShadow(fb, 5, 5, buf, COL_TEXT, COL_SHADOW);
// Добыча — вторая по важности цифра забега после этажа: именно она стоит
// на кону, и знать её нужно ДО того, как решаешь идти глубже.
// Сумка одна на отряд, поэтому и цифра одна: пересчитывать по бойцам
// больше нечего.
const int carried = game.squad.bag.Count();
std::snprintf(buf, sizeof(buf), "XP %d LOOT %d", int(game.xp), carried);
MicroTextShadow(fb, 5, 5 + MICRO_H + 3, buf, COL_TEXT, COL_SHADOW);
// Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка
// «нажми F» висела бы на экране весь забег и перестала бы читаться.
//
// Разные цвета — те же, что у колец на полу: подсказка обязана читаться
// как подпись к тому, на чём стоишь, а не как отдельное сообщение.
const char* hint = nullptr;
uint32_t hintCol = COL_READY;
if (game.atExtract) { hint = "EXTRACT: E"; hintCol = COL_OUT; }
else if (game.atExit) { hint = "DESCEND: F"; hintCol = COL_READY; }
if (!hint) return;
MicroTextShadow(fb, (fb.w - MicroTextWidth(hint)) / 2, 14, hint, hintCol, COL_SHADOW);
}
} // namespace
void DrawHud(Framebuffer& fb, const Game& game)
{
DrawRunBar(fb, game);
const int total = NUM_AGENTS * CARD_W + (NUM_AGENTS - 1) * CARD_GAP;
const int x0 = (fb.w - total) / 2;
const int y0 = fb.h - CARD_H - 4;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
const int x = x0 + i * (CARD_W + CARD_GAP);
const uint32_t col = SlotColor(i);
// Подложка вспыхивает, когда бойца ударили: цифры смотреть некогда,
// а красная карточка в углу глаза видна.
uint32_t back = RGBA(12, 13, 16);
if (!a.alive) back = COL_DEAD;
else if (a.hurtTimer > 0.0f)
back = ShadeColor(RGBA(120, 30, 30), 0.4f + a.hurtTimer);
Panel(fb, x, y0, CARD_W, CARD_H, back);
// Метка слота: тот же цвет, что и у тела на карте.
fb.FillRect(x + 3, y0 + 3, 4, CARD_H - 6, a.alive ? col : ShadeColor(col, 0.35f));
if (!a.alive)
{
// Перечёркнутая карточка: потерю отряда нельзя пропустить.
fb.Line(x + 10, y0 + 4, x + CARD_W - 6, y0 + CARD_H - 5, RGBA(180, 60, 60));
fb.Line(x + 10, y0 + CARD_H - 5, x + CARD_W - 6, y0 + 4, RGBA(180, 60, 60));
continue;
}
// Здоровье.
const float hpK = Clampf(a.hp / std::max(1.0f, a.maxHp), 0.0f, 1.0f);
const uint32_t hpCol = (hpK > 0.5f) ? RGBA(150, 200, 130)
: (hpK > 0.25f) ? RGBA(220, 190, 90)
: RGBA(220, 80, 70);
fb.FillRect(x + 10, y0 + 4, BAR_W, 5, COL_EMPTY);
fb.FillRect(x + 10, y0 + 4, int(float(BAR_W) * hpK), 5, hpCol);
// Броня: тонкая полоска под здоровьем. Её нет — нет и полоски.
if (a.armor > 0.0f)
fb.FillRect(x + 10, y0 + 10, int(float(BAR_W) * Clampf(a.armor / ARMOR_CAP, 0.0f, 1.0f)),
2, RGBA(120, 150, 190));
// Нагрузка. Полоска появляется ТОЛЬКО за нормой переноски: пока боец
// идёт как шёл, показывать нечего, а лишняя линия в каждой карточке
// отняла бы внимание у здоровья и патронов.
//
// Она растёт от нормы к пределу и там же кончается — это и есть шкала
// «насколько ты уже обоз».
if (a.load > CARRY_LIMIT)
{
const float over = Clampf((a.load - CARRY_LIMIT) / (CARRY_MAX - CARRY_LIMIT),
0.0f, 1.0f);
fb.FillRect(x + 10, y0 + 12, BAR_W, 2, COL_EMPTY);
fb.FillRect(x + 10, y0 + 12, int(float(BAR_W) * over), 2,
(over > 0.85f) ? RGBA(220, 90, 60) : RGBA(200, 160, 70));
}
// Оружие: патроны или фаза клинка.
if (a.IsMelee()) DrawMeleeState(fb, x + 10, y0 + 14, a, col);
else
{
DrawAmmo(fb, x + 10, y0 + 14, a, col);
DrawReserve(fb, x + 10, y0 + 19, a);
}
// Иконка того, что в руках.
fb.Blit(game.sprites.Icon(a.held), x + CARD_W - 12, y0 + CARD_H / 2);
// Есть цель — уголок над карточкой. Отряд, который никого не видит,
// должен отличаться от отряда, который работает.
const bool engaged = a.IsMelee() ? a.melee.inReach : a.hasLane;
if (engaged) fb.FillRect(x + CARD_W / 2 - 3, y0 - 3, 6, 2, RGBA(230, 220, 200));
}
}