squad-proto/src/render/hud.cpp
z.kirill 53ea43f2a4 Пистолет не пропадает, эвакуация откуда угодно, кнопки в HUD (M36)
БАГ С ПИСТОЛЕТОМ - на самом деле ДВА бага.

Первый: экран отряда правит ЖИВОЙ МИР, а вылазка собирает бойцов заново
из профиля (NewRun пересоздаёт ecs::World и раздаёт снаряжение через
ApplyProfileLoadout). Всё, переложенное в меню, стиралось на старте, а
вещь, вынутая из схрона, исчезала вместе с миром - схрон её уже отдал.
Теперь всякая правка снаряжения пишется в профиль сразу.

Второй, рядом: перетаскивание мышью НЕ РАБОТАЛО ВООБЩЕ. Флаг dropOk
нигде не выставлялся в true - только сбрасывался, - и сброс молча
отменялся. Единственным рабочим способом переодеться оставался ENTER.
Теперь флаг считается каждый кадр, и ячейка честно красится в "нельзя".

Заодно: вне забега сумка не принимает вещей. Она принадлежит вылазке и
очищается при сборке этажа; вещь, положенная в неё из меню, исчезла бы
так же молча.

Проверка ловит именно цепочку "поменял в меню -> начал вылазку -> вышел
с этим": сам обмен работал и раньше, пропадал он на старте.

  сборы: в профиле после обмена PISTOL, на этаже PISTOL

ТВАРИ НЕ ВОСКРЕСАЮТ. Убитая в этой вылазке убита насовсем: с возвратом
любая расчищенная комната через полминуты снова была полна, и "пробиться
и уйти" не отличалось от "молотить бесконечную очередь". Исключение одно
- аура плакальщика, это его роль.

ЭВАКУАЦИЯ С ЛЮБОГО ЭТАЖА И ИЗ ЛЮБОЙ ТОЧКИ. Раньше уйти можно было только
стоя на кольце: отряд, решивший не рисковать, всё равно шёл через этаж и
умирал ровно там, где игрок уже принял решение. Чекпоинты теперь значат
только то, чем должны быть: откуда можно НАЧАТЬ следующую вылазку.

ОТЗЫВЧИВОСТЬ СТРОЯ. Отряд ходил 1.75 при курсоре 3.3 - он всегда
отставал, и управление ощущалось как "тяну за верёвку". Стало 2.6,
поводок короче (2.5 против 3.0), доворот строя вдвое резче.

Ориентация строя теперь ПОВОРАЧИВАЕТСЯ, а не щёлкает: раньше слоты клина
перепрыгивали через весь отряд на каждом повороте, и бойцы бросали дорогу
ради перестроения. В тесноте доворот почти мгновенный - там строй и так
колонна, а колонна, смотрящая в прошлую сторону, упирается в стену.

Автопилот поймал, что первая версия правок сделала хуже: при быстром
наборе натяжения (5.2) отряд уходил в вечный спринт, а спринт растягивает
строй и выключает огонь. Порог спринта поднят до 0.95, набор до 3.4 -
приёмка 11.4 держится (0.13 с при пороге 0.30).

КНОПКИ В HUD. Слева внизу четыре: SQUAD, TREE, WORK, MENU. Экраны
открывались только клавишами, о которых игра нигде не говорила, - без
кнопки мастерская и дерево не открываются никогда.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 05:24:10 +03:00

257 lines
13 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» висела бы на экране весь забег и перестала бы читаться.
//
// Разные цвета — те же, что у колец на полу: подсказка обязана читаться
// как подпись к тому, на чём стоишь, а не как отдельное сообщение.
// Уйти можно откуда угодно, поэтому «EXTRACT: E» висит всегда — но тускло и
// мелко, как справка. Спуск наоборот: он привязан к месту, и его подсказка
// появляется только на кольце, зато в полную силу.
if (game.atExit)
MicroTextShadow(fb, (fb.w - MicroTextWidth("DESCEND: F")) / 2, 14, "DESCEND: F",
COL_READY, COL_SHADOW);
MicroTextShadow(fb, fb.w - MicroTextWidth("EXTRACT: E") - 5, 5, "EXTRACT: E", COL_OUT,
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));
}
}