squad-proto/src/render/hud.cpp
z.kirill ece906cbb3 Отряд растёт с одного оперативника до пяти (M24)
Игра начинается ОДНИМ человеком: тактический нож, слабый фонарь, куртка.
Остальные четверо нанимаются за опыт по кривой 600/2400/9600/38400 —
самая дорогая покупка в игре и намеренно. Одиночка с ножом это первое
впечатление, и продать второго бойца за одну вылазку значило бы выбросить
весь хоррор начала: вдвоём уже не страшно.

NUM_AGENTS осталась величиной времени сборки и означает теперь ВМЕСТИМОСТЬ,
а не размер отряда: делать её динамической это сотня мест ради числа,
которое всё равно упирается в пять. Занятость слота это Agent::present, и
он отличается от alive. Правила боя читают только alive — для них пустой
слот и павший одно, и это верно. Разница нужна там, где показывают
человека: у павшего есть тело на полу и перечёркнутая карточка, у пустого
слота нет ни того, ни другого.

Проверка в --descend меряет цепочку «в профиле -> на этаже» и потолок, а
главное — переживает ли одиночка первую схватку:

  отряд: в профиле 1 -> на этаже 1, после найма 2, цена 600, сверх потолка отказ
         одиночка с ножом на первом этаже: выжил, 100 -> 41, подошло 2, убил 2

Первая версия этой проверки не измеряла ничего: боец стоял минуту, к нему
не подошёл никто, и «выжил» было пустым словом. Теперь отряд ставится
рядом с тварью.

Попутно найдено и исправлено: Profile::Save три вехи не писала ни версию
формата, ни улучшения. Купленный запас патронов действовал до конца
запуска и молча пропадал при чтении, а профиль без версии читался как
формат 1. Круговой прогон этого не видел, потому что сверял три поля из
десяти и делал это на пустом профиле, где всё сходится само. Теперь он
сверяет поля списком и на набитом профиле.

Формат профиля 3: добавлена строка squad. Старый профиль играл впятером и
получает пятерых при миграции — отобрать их значило бы съесть отряд,
которого игрок не терял.

--accept PASS без изменений: без профиля отряд по-прежнему впятером.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 22:29:19 +03:00

255 lines
12 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);
// Свет — второй ресурс вылазки, и таять он обязан на глазах так же, как
// патроны в карточке бойца. Разница одна: кончившийся заряд показывается
// СЛОВОМ, а не полосой нулевой длины. Темнота меняет правила — и обзор, и
// то, кто кого замечает первым, — а «полоски не видно» читалось бы как
// «полоски тут и не было».
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));
}
}
// Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка
// «нажми 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);
// Карточки только на ЗАНЯТЫЕ слоты, и ряд считается по ним же. Отряд растёт
// с одного до пяти, и четыре пустые рамки рядом с одиноким оперативником
// читались бы как «тут кто-то был» — а там никого и не было.
int manned = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
if (game.squad.agents[i].present) ++manned;
if (manned <= 0) return;
const int total = manned * CARD_W + (manned - 1) * CARD_GAP;
const int x0 = (fb.w - total) / 2;
const int y0 = fb.h - CARD_H - 4;
int card = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
const Agent& a = game.squad.agents[i];
if (!a.present) continue;
const int x = x0 + (card++) * (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));
}
}