БАГ С ПИСТОЛЕТОМ - на самом деле ДВА бага. Первый: экран отряда правит ЖИВОЙ МИР, а вылазка собирает бойцов заново из профиля (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>
257 lines
13 KiB
C++
257 lines
13 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);
|
||
|
||
// Свет — второй ресурс вылазки, и таять он обязан на глазах так же, как
|
||
// патроны в карточке бойца. Разница одна: кончившийся заряд показывается
|
||
// СЛОВОМ, а не полосой нулевой длины. Темнота меняет правила — и обзор, и
|
||
// то, кто кого замечает первым, — а «полоски не видно» читалось бы как
|
||
// «полоски тут и не было».
|
||
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));
|
||
}
|
||
}
|