226 lines
11 KiB
C++
226 lines
11 KiB
C++
|
|
#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));
|
|||
|
|
}
|
|||
|
|
}
|