squad-proto/src/render/hud.cpp

245 lines
12 KiB
C++
Raw Normal View History

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-14 01:07:19 +07:00
#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);
Свет как расходник: заряд тратится за путь, а не за время (M23) Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он меняется с тварями местами. Батарея встаёт сама, как бинт. Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия считала по секундам, то есть была таймером, которого в игре нет по прямому требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как секундомер, только без циферблата, и думать над этажом стало бы дорого. За расстояние получается тот же размен, что и с весом, но с другой стороны: вес делает дорогим «унести всё», свет — «обшарить всё». Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды. Проверка в --descend меряет правило целиком, и первая её строка не про баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже был неправ — делил потраченное на смещение отряда, а не на пройденный путь, и показывал 1.55 за тайл при 1.10 в каталоге. свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска обзор 1.00 -> 0.35 в темноте, батарея встала сама Хватает ли заряда на этаж считается в этажах и по средней длине забега: путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы удачу, а не баланс. --accept PASS без изменений: в песочнице света бесконечно, как и патронов. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
// Свет — второй ресурс вылазки, и таять он обязан на глазах так же, как
// патроны в карточке бойца. Разница одна: кончившийся заряд показывается
// СЛОВОМ, а не полосой нулевой длины. Темнота меняет правила — и обзор, и
// то, кто кого замечает первым, — а «полоски не видно» читалось бы как
// «полоски тут и не было».
if (game.squad.lightCharge >= 0.0f)
{
const int ly = 5 + (MICRO_H + 3) * 2;
if (game.squad.LightOut())
MicroTextShadow(fb, 5, ly, "DARK", RGBA(225, 95, 70), COL_SHADOW);
else
{
const float k = Clampf(game.squad.lightCharge / LIGHT_FULL, 0.0f, 1.0f);
fb.FillRect(5, ly + 1, 46, 3, RGBA(44, 48, 56));
fb.FillRect(5, ly + 1, std::max(1, int(46.0f * k)), 3,
(k < 0.25f) ? RGBA(220, 150, 60) : RGBA(232, 214, 150));
}
}
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-14 01:07:19 +07:00
// Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка
// «нажми 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));
}
}