squad-proto/src/ui/menu.cpp
z.kirill b29358ad12 Инвентарь: SHIFT+клик, сортировка схрона, сравнение с надетым (M38)
Перетаскивание мышью в кадре 480x270 — это попадание в ячейку 74x18 с зажатой
кнопкой, и делать так тридцать раз после каждой вылазки нельзя.

* SHIFT + клик отправляет вещь ТУДА, ГДЕ ЕЙ МЕСТО: ствол в руки, плита в броню,
  брелок в карман, расходник в сумку; из слота — обратно в схрон. Обмен идёт
  через тот же ApplyDrop, что и перетаскивание: своя копия правил означала бы
  ещё одно место, где вещь может потеряться.
* Кнопка SORT раскладывает схрон: класс -> редкость -> аффиксы -> имя.
* Подсказка вещи из схрона дописывает блок NOW: что стоит в этом слоте сейчас
  и чем новое отличается.

--ui-check меряет сохранность, а не факт вызова: вещей было 5 — стало 5, ствол
в руках, прежний в схроне, профиль о переодевании знает. Проверка сперва
воспроизведена на сломанной сборке: без SaveLoadout печатает «профиль НЕ знает».

Замеры: --ui-check поломок 0, --accept PASS, --descend 8 ошибок 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 18:42:28 +03:00

2023 lines
96 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 "ui/menu.h"
#include <raylib.h>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include "game.h"
#include "sim/affix.h"
#include "sim/craft.h"
#include "sim/tree.h"
namespace
{
// --- палитра -----------------------------------------------------------------
const Color COL_DIM = {6, 6, 8, 218};
const Color COL_TEXT = {206, 200, 190, 255};
const Color COL_FAINT = {110, 106, 100, 255};
const Color COL_PICK = {236, 226, 206, 255};
const Color COL_BLOOD = {150, 44, 40, 255};
const Color COL_DEAD = {60, 58, 56, 255}; // пункт, который сейчас не работает
const Color COL_EDGE = {58, 54, 52, 255}; // рамка спокойной ячейки
const Color COL_HOVER = {74, 40, 34, 130}; // подложка под курсором
const Color COL_REJECT = {150, 40, 34, 150}; // слот не примет то, что тащат
const Color COL_SHADOW = {8, 6, 6, 220}; // контур курсора
// -----------------------------------------------------------------------------
// Кадр 480x270 лежит в окне со сдвигом и ЦЕЛЫМ масштабом (ScreenFitRect),
// поэтому вся разметка ниже задана в пикселях кадра и переводится в окно только
// здесь. Считать в пикселях окна нельзя: на каждом мониторе множитель свой,
// и меню разъедется с картинкой.
// -----------------------------------------------------------------------------
struct Layout
{
int x = 0, y = 0; // левый верхний угол кадра в окне
int u = 1; // размер «пикселя» кадра в пикселях окна
int w = 0, h = 0; // размер кадра в пикселях окна
int fw = 0, fh = 0; // размер кадра в его собственных пикселях
};
Layout Of(const ScreenRect& fit)
{
Layout l;
l.u = (fit.scale > 0) ? fit.scale : 1;
l.x = fit.x;
l.y = fit.y;
l.w = fit.w;
l.h = fit.h;
l.fw = fit.w / l.u;
l.fh = fit.h / l.u;
return l;
}
// Текст в координатах КАДРА, а не окна: меню обязано жить по той же сетке,
// что и картинка, иначе на разных мониторах оно разъезжается.
void Text(const Layout& l, const char* s, int fx, int fy, int size, Color c)
{
DrawText(s, l.x + fx * l.u, l.y + fy * l.u, size * l.u, c);
}
// Ширина строки В ПИКСЕЛЯХ КАДРА. Меряем на том же кегле, каким будем рисовать,
// и делим обратно: у встроенного шрифта raylib межбуквенный интервал зависит
// от размера, и «померить на 8, нарисовать на 24» дало бы другую ширину.
int TextW(const Layout& l, const char* s, int size)
{
return MeasureText(s, size * l.u) / l.u;
}
void TextCentered(const Layout& l, const char* s, int fy, int size, Color c)
{
Text(l, s, (l.fw - TextW(l, s, size)) / 2, fy, size, c);
}
void Box(const Layout& l, const UiRect& r, Color c)
{
DrawRectangle(l.x + r.x * l.u, l.y + r.y * l.u, r.w * l.u, r.h * l.u, c);
}
void BoxLines(const Layout& l, const UiRect& r, Color c)
{
const Rectangle rr = {float(l.x + r.x * l.u), float(l.y + r.y * l.u), float(r.w * l.u),
float(r.h * l.u)};
DrawRectangleLinesEx(rr, float(l.u), c);
}
void Shade(const Layout& l, Color c)
{
DrawRectangle(l.x, l.y, l.w, l.h, c);
}
// Полоска-разделитель под заголовком.
void Rule(const Layout& l, int fy, int halfWidth)
{
Box(l, UiRect{l.fw / 2 - halfWidth, fy, halfWidth * 2, 1}, COL_BLOOD);
}
// -----------------------------------------------------------------------------
// Сетка экранов. Все числа — в пикселях кадра и живут ровно здесь; сами
// прямоугольники собираются один раз в BuildWidgets, а рисование и попадание
// мыши читают уже готовую таблицу. Разъехаться подсветке и клику негде.
// -----------------------------------------------------------------------------
constexpr int BTN_PAD_X = 10; // поле кнопки вокруг подписи
constexpr int BTN_PAD_Y = 4;
constexpr int MENU_Y0 = 122, MENU_STEP = 20, MENU_SIZE = 12;
constexpr int PAUSE_Y0 = 132, PAUSE_STEP = 20, PAUSE_SIZE = 11;
constexpr int DEFEAT_Y0 = 172, DEFEAT_STEP = 22, DEFEAT_SIZE = 11;
// Экран отряда: карточки бойцов, три слота снаряжения, сетка сумки.
constexpr int SQ_CARD_X = 22, SQ_CARD_Y = 40, SQ_CARD_W = 76, SQ_CARD_H = 30;
constexpr int SQ_CARD_STEP = 88;
constexpr int SQ_EQUIP_X = 34, SQ_EQUIP_Y = 88, SQ_EQUIP_W = 180, SQ_EQUIP_H = 20;
constexpr int SQ_EQUIP_STEP = 23;
constexpr int SQ_LABEL_W = 46; // место под слово HANDS/ARMOR/POCKET внутри слота
constexpr int SQ_STAT_X = 40, SQ_STAT_Y = 158, SQ_STAT_STEP = 9;
// Экран улучшений: четыре строки во всю ширину. Строкой, а не сеткой, потому
// что у каждой есть длинная подпись «что это даёт», и без неё покупка была бы
// покупкой кота в мешке.
// Дерево улучшений: пять столбцов-ветвей по восемь узлов (sim/tree.h).
//
// Сетка, а не свободная раскладка с кривыми связями: узлы читаются столбцами,
// ветвь видна целиком, и «куда я иду» понятно без легенды. Кривые связи из
// больших деревьев красивы на скриншоте и нечитаемы на 480x270.
constexpr int TR_X = 30, TR_Y = 66;
constexpr int TR_COL_W = 86, TR_ROW_H = 23;
constexpr int TR_NODE_W = 78, TR_NODE_H = 19;
inline UiRect TreeNodeRect(int index)
{
const int col = index / NODES_PER_BRANCH;
const int row = index % NODES_PER_BRANCH;
return UiRect{TR_X + col * TR_COL_W, TR_Y + row * TR_ROW_H, TR_NODE_W, TR_NODE_H};
}
constexpr int SQ_STAT_LINES = 4;
// Сумка общая, и ячеек в ней шестнадцать вместо шести на бойца. В два столбца
// они не влезают, поэтому сетка 4x4 клетками поменьше: имя вещи в такую клетку
// не помещается, зато помещается иконка — а что это, теперь говорит подсказка
// под курсором.
constexpr int SQ_BAG_X = 262, SQ_BAG_Y = 100, SQ_BAG_W = 46, SQ_BAG_H = 20;
constexpr int SQ_BAG_GX = 5, SQ_BAG_GY = 4, SQ_BAG_COLS = 4;
constexpr int SQ_HINT_Y = 250, SQ_HINT_STEP = 10;
// Схрон: то, что лежит на базе между вылазками. Двумя рядами во всю ширину
// внизу, а не колонкой сбоку: в схроне десятки предметов, и подпись каждого
// должна читаться — иначе выбор снаряжения превращается в перебор иконок.
// Ячейка схрона шире, а колонок меньше: в 52 пикселя не влезало даже слово
// BATTERY, и половина имён обрезалась на середине. Читаемость важнее, чем ещё
// две ячейки в ряду: прокрутка есть, а угадывать вещь по обрубку — нет.
constexpr int SQ_STASH_X = 16, SQ_STASH_Y = 206, SQ_STASH_W = 74, SQ_STASH_H = 18;
constexpr int SQ_STASH_GX = 4, SQ_STASH_GY = 3, SQ_STASH_COLS = 6, SQ_STASH_ROWS = 2;
// Кнопка «разложить»: справа от заголовка схрона, там же, где подсказка о
// прокрутке. Схрон на 48 ячеек, вещи валятся в порядке подбора.
constexpr int SQ_SORT_X = 168, SQ_SORT_W = 34, SQ_SORT_H = 10;
constexpr int SQ_STASH_CELLS = SQ_STASH_COLS * SQ_STASH_ROWS;
// Мастерская. Слева схрон той же сеткой, что и на экране отряда (привычка
// игрока дороже экономии места), справа пять операций строками с ценой.
constexpr int WS_SLOT_X = 16, WS_SLOT_Y = 74, WS_SLOT_W = 52, WS_SLOT_H = 18;
constexpr int WS_SLOT_GX = 4, WS_SLOT_GY = 3, WS_SLOT_COLS = 4, WS_SLOT_ROWS = 6;
constexpr int WS_SLOT_CELLS = WS_SLOT_COLS * WS_SLOT_ROWS;
// Семь операций: строки ужаты, чтобы последняя не налезала на подсказку
// внизу экрана. Кадр 480x270, и место здесь считается по-настоящему.
constexpr int WS_OP_X = 252, WS_OP_Y = 84, WS_OP_W = 212, WS_OP_H = 20, WS_OP_STEP = 22;
// -----------------------------------------------------------------------------
// Нижняя панель боя: четыре кнопки, открывающие экраны.
//
// До неё все экраны открывались только клавишами (I, U, ESC), и знать их было
// неоткуда: игра нигде о них не говорит. Кнопка — это ещё и НАПОМИНАНИЕ, что
// экраны существуют, а мастерская и дерево без такого напоминания просто не
// открываются никогда.
//
// Панель слева, карточки бойцов по центру: они и так занимают середину, и
// класть кнопки под них значило бы драться за то же место.
constexpr int HB_X = 4, HB_W = 40, HB_H = 11, HB_GAP = 2;
constexpr int HB_COUNT = 4;
// SQUAD, TREE, WORKSHOP, MENU. Порядок — порядок частоты нажатий.
const char* const HUD_BUTTONS[HB_COUNT] = {"SQUAD", "TREE", "WORK", "MENU"};
inline UiRect HudButtonRect(int i, int frameH)
{
const int y = frameH - (HB_COUNT - i) * (HB_H + HB_GAP) - 2;
return UiRect{HB_X, y, HB_W, HB_H};
}
// Выбор чекпоинта: столбец кнопок «начать с этажа N».
constexpr int DEPTH_Y0 = 92, DEPTH_STEP = 18, DEPTH_SIZE = 10;
constexpr int DEPTH_MAX_SHOWN = 7; // больше на экран не влезает
constexpr int SUM_Y0 = 214, SUM_STEP = 20, SUM_SIZE = 11;
const char* const MENU_ITEMS[] = {"CONTINUE", "NEW RAID", "SQUAD",
"UPGRADES", "WORKSHOP", "QUIT"};
constexpr int MENU_COUNT = 6;
const char* const PAUSE_ITEMS[] = {"RESUME", "SQUAD", "MENU"};
constexpr int PAUSE_COUNT = 3;
const char* const DEFEAT_ITEMS[] = {"TRY AGAIN", "MENU"};
constexpr int DEFEAT_COUNT = 2;
const char* const SUMMARY_ITEMS[] = {"CONTINUE"};
constexpr int SUMMARY_COUNT = 1;
const char* const EQUIP_LABELS[] = {"HANDS", "ARMOR", "POCKET"};
constexpr UiPart EQUIP_PARTS[] = {UiPart::HANDS, UiPart::ARMOR, UiPart::POCKET};
constexpr int EQUIP_COUNT = 3;
// -----------------------------------------------------------------------------
// Таблица интерактивных элементов текущего экрана.
// -----------------------------------------------------------------------------
struct Widget
{
UiPart part = UiPart::NONE;
int index = 0;
UiRect rect;
const char* text = nullptr; // подпись кнопки; у ячеек её нет
int size = 0; // кегль подписи в пикселях кадра
ItemInstance item; // что лежит в ячейке (с аффиксами)
bool enabled = true;
};
struct Widgets
{
// 5 карточек + 3 слота + 16 ячеек общей сумки + 4 строки статов +
// 16 ячеек схрона + кнопки экранов.
//
// Считать это «с запасом» нельзя: при переполнении лишние виджеты молча
// не создаются, и экран теряет ХВОСТ — так после перехода на общую сумку
// из схрона пропали последние ячейки, а выглядело это как ошибка вёрстки.
static constexpr int MAX = 64;
static_assert(MAX >= NUM_AGENTS + 3 + SQUAD_BAG_SLOTS + SQ_STASH_CELLS + 8,
"виджетов экрана отряда больше, чем помещается");
Widget item[MAX];
int count = 0;
Widget& Add(UiPart part, int index, const UiRect& r)
{
Widget& w = item[(count < MAX) ? count : (MAX - 1)];
if (count < MAX) ++count;
w = Widget{};
w.part = part;
w.index = index;
w.rect = r;
return w;
}
const Widget* At(float fx, float fy) const
{
for (int i = 0; i < count; ++i)
if (item[i].rect.Has(fx, fy)) return &item[i];
return nullptr;
}
const Widget* Find(UiPart part, int index) const
{
for (int i = 0; i < count; ++i)
if (item[i].part == part && item[i].index == index) return &item[i];
return nullptr;
}
};
// Кнопка по центру кадра: прямоугольник считается по реальной ширине подписи,
// подпись потом рисуется ИЗ него же (rect + поле), а не отдельным центрированием.
// -----------------------------------------------------------------------------
// Подсказка под курсором.
//
// Без неё предмет — это имя и цветной квадратик, и что даёт VEST против JACKET,
// узнать было неоткуда: числа лежали в каталоге кода. С общей сумкой на
// шестнадцать мелких ячеек это перестало быть терпимым — в ячейку не влезает
// даже имя.
//
// Тексты латиницей: встроенный шрифт raylib кириллицы не знает и рисует её
// вопросами (CLAUDE.md). Это подпись НА ЭКРАНЕ, а не вывод в консоль.
// -----------------------------------------------------------------------------
constexpr int TIP_LINES_MAX = 7;
struct Tip
{
char lines[TIP_LINES_MAX][40] = {};
int count = 0;
void Add(const char* fmt, ...)
{
if (count >= TIP_LINES_MAX) return;
va_list args;
va_start(args, fmt);
vsnprintf(lines[count], sizeof(lines[0]), fmt, args);
va_end(args);
++count;
}
};
const char* KindName(ItemKind k)
{
switch (k)
{
case ItemKind::FIREARM: return "FIREARM";
case ItemKind::BLADE: return "BLADE";
case ItemKind::ARMOR: return "ARMOR";
case ItemKind::TRINKET: return "TRINKET";
case ItemKind::CONSUMABLE: return "SUPPLY";
default: return "-";
}
}
// Что вещь делает. Только те строки, которые у неё не нулевые: список из шести
// «+0%» читается как «ничего не даёт», хотя даёт.
Tip ItemTip(const ItemInstance& it)
{
const ItemDef d = ItemDefOf(it.id);
Tip t;
// Редкость в заголовке, а не отдельной строкой: игрок читает подсказку
// сверху вниз и первое, что ему нужно знать про найденную вещь, — стоит ли
// читать дальше.
if (it.rarity != Rarity::COMMON)
t.Add("%s %s (%s)", RarityName(it.rarity), d.name, KindName(d.kind));
else
t.Add("%s (%s)", d.name, KindName(d.kind));
t.Add("WEIGHT %.1f", double(d.weight));
if (d.armor > 0.0f) t.Add("ARMOR +%d%%", int(d.armor * 100.0f + 0.5f));
if (d.speedMul != 1.0f) t.Add("SPEED %+d%%", int((d.speedMul - 1.0f) * 100.0f));
if (d.hpBonus > 0.0f) t.Add("HP +%d", int(d.hpBonus));
if (d.sightMul != 1.0f) t.Add("SIGHT %+d%%", int((d.sightMul - 1.0f) * 100.0f));
if (d.heal > 0.0f) t.Add("HEALS %d WHEN SAFE", int(d.heal));
if (d.rounds > 0) t.Add("%d ROUNDS WHEN DRY", d.rounds);
if (d.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge));
// Аффиксы — отдельным блоком после базовых чисел. Смешивать их со статами
// предмета нельзя: базовое приходит с видом вещи и одинаково у всех, а
// выкатанное — то, ради чего эту конкретную вещь подобрали.
for (int i = 0; i < it.Count(); ++i)
{
char line[64];
AffixLabel(it.affix[i], line, int(sizeof(line)));
t.Add("%s", line);
}
return t;
}
// Сравнение с надетым. Без него выбор снаряжения — это запоминание чисел:
// игрок наводит на найденную плиту, видит «ARMOR +18%», уходит смотреть, что
// на бойце, возвращается и уже не помнит первое. Дописываем прямо в ту же
// подсказку, отдельным блоком после аффиксов.
//
// Сравнивается ТО, ЧТО ЗАМЕНИТСЯ: класс вещи выбирает слот, слот даёт текущую
// вещь. Если слот пуст или это та же самая вещь, блока нет вовсе.
void AddCompare(Tip& t, const ItemInstance& it, const Inventory& inv)
{
const ItemDef d = ItemDefOf(it.id);
const ItemInstance* now = nullptr;
if (IsWeaponItem(it.id)) now = &inv.weapon;
else if (d.kind == ItemKind::ARMOR) now = &inv.armor;
else if (d.kind == ItemKind::TRINKET) now = &inv.trinket;
if (!now || now->Empty() || now->id == it.id) return;
const ItemDef c = ItemDefOf(now->id);
t.Add("NOW: %s", c.name);
// Только те строки, где есть РАЗНИЦА: «ARMOR +0%» не говорит ничего.
const int dArmor = int((d.armor - c.armor) * 100.0f + (d.armor >= c.armor ? 0.5f : -0.5f));
const int dSpeed = int((d.speedMul - c.speedMul) * 100.0f +
(d.speedMul >= c.speedMul ? 0.5f : -0.5f));
const int dHp = int(d.hpBonus - c.hpBonus);
const int dSight = int((d.sightMul - c.sightMul) * 100.0f +
(d.sightMul >= c.sightMul ? 0.5f : -0.5f));
const float dWeight = d.weight - c.weight;
if (dArmor != 0) t.Add(" ARMOR %+d%%", dArmor);
if (dSpeed != 0) t.Add(" SPEED %+d%%", dSpeed);
if (dHp != 0) t.Add(" HP %+d", dHp);
if (dSight != 0) t.Add(" SIGHT %+d%%", dSight);
if (dWeight < -0.05f || dWeight > 0.05f) t.Add(" WEIGHT %+.1f", double(dWeight));
}
Tip StatTip(int line, const Agent& a)
{
Tip t;
switch (line)
{
case 0:
t.Add("HEALTH");
t.Add("CARRIES DOWN BETWEEN FLOORS.");
t.Add("BANDAGE IS USED WHEN SAFE.");
break;
case 1:
t.Add("ARMOR");
t.Add("SHARE OF DAMAGE ABSORBED,");
t.Add("CAPPED AT %d%%.", int(ARMOR_CAP * 100.0f));
break;
case 2:
t.Add("SPEED AND LOAD");
t.Add("GEAR AND WEIGHT BOTH SLOW YOU.");
t.Add("FREE UNTIL %.1f, CRAWL AT %.0f.", double(CARRY_LIMIT), double(CARRY_MAX));
t.Add("SQUAD BAG SPLITS OVER SURVIVORS.");
break;
default:
t.Add("SIGHT");
t.Add("HOW FAR THIS FIGHTER SEES.");
t.Add("DEPTH SHRINKS IT FOR EVERYONE.");
break;
}
(void)a;
return t;
}
void DrawTip(const Layout& l, const Tip& t, int mx, int my)
{
if (t.count <= 0) return;
const int pad = 4;
const int step = 9;
int wide = 0;
for (int i = 0; i < t.count; ++i) wide = std::max(wide, TextW(l, t.lines[i], 8));
// Подсказка встаёт ВЫШЕ курсора, а не ниже.
//
// Ниже она ложилась ровно на строки характеристик — те самые, которые
// объясняет: игрок наводил на броню, чтобы понять, откуда 28%, и подсказка
// закрывала строку ARMOR. Сверху под ней только пустое место.
UiRect box{mx + 8, my - 6 - (t.count * step + pad * 2 - 2), wide + pad * 2,
t.count * step + pad * 2 - 2};
// И не должна вылезать за кадр: у края экрана она переезжает на другую
// сторону курсора, а не обрезается.
if (box.x + box.w > INTERNAL_W - 2) box.x = mx - 8 - box.w;
if (box.y + box.h > INTERNAL_H - 2) box.y = INTERNAL_H - 2 - box.h;
if (box.x < 2) box.x = 2;
if (box.y < 2) box.y = 2;
Box(l, box, Color{10, 10, 13, 245});
BoxLines(l, box, COL_EDGE);
for (int i = 0; i < t.count; ++i)
Text(l, t.lines[i], box.x + pad, box.y + pad + i * step, 8,
(i == 0) ? COL_TEXT : COL_FAINT);
}
UiRect Button(const Layout& l, const char* s, int fy, int size)
{
UiRect r;
r.w = TextW(l, s, size) + BTN_PAD_X * 2;
r.h = size + BTN_PAD_Y * 2;
r.x = (l.fw - r.w) / 2;
r.y = fy - BTN_PAD_Y;
return r;
}
UiRect BagRect(int slot)
{
return UiRect{SQ_BAG_X + (slot % SQ_BAG_COLS) * (SQ_BAG_W + SQ_BAG_GX),
SQ_BAG_Y + (slot / SQ_BAG_COLS) * (SQ_BAG_H + SQ_BAG_GY), SQ_BAG_W, SQ_BAG_H};
}
UiRect StashRect(int cell)
{
return UiRect{SQ_STASH_X + (cell % SQ_STASH_COLS) * (SQ_STASH_W + SQ_STASH_GX),
SQ_STASH_Y + (cell / SQ_STASH_COLS) * (SQ_STASH_H + SQ_STASH_GY), SQ_STASH_W,
SQ_STASH_H};
}
UiRect CraftSlotRect(int cell)
{
return UiRect{WS_SLOT_X + (cell % WS_SLOT_COLS) * (WS_SLOT_W + WS_SLOT_GX),
WS_SLOT_Y + (cell / WS_SLOT_COLS) * (WS_SLOT_H + WS_SLOT_GY), WS_SLOT_W,
WS_SLOT_H};
}
UiRect CraftOpRect(int op) { return UiRect{WS_OP_X, WS_OP_Y + op * WS_OP_STEP, WS_OP_W, WS_OP_H}; }
void AddButtons(Widgets& out, const Layout& l, UiPart part, const char* const* names, int count,
int y0, int step, int size)
{
for (int i = 0; i < count; ++i)
{
Widget& w = out.Add(part, i, Button(l, names[i], y0 + i * step, size));
w.text = names[i];
w.size = size;
}
}
// Единственное место, где рождаются прямоугольники. И Update, и Draw зовут его
// с одним и тем же Layout, поэтому видят ровно одну разметку.
void BuildWidgets(const Ui& ui, const Game& game, const Layout& l, Widgets& out)
{
out.count = 0;
switch (ui.screen)
{
case Screen::GAME:
// Кнопки живут и в бою: экраны вне боя должны открываться мышью, а
// не только клавишами, о которых игра нигде не говорит.
for (int i = 0; i < HB_COUNT; ++i)
{
Widget& b = out.Add(UiPart::HUD_BUTTON, i, HudButtonRect(i, l.fh));
b.text = HUD_BUTTONS[i];
b.size = 7;
}
return;
case Screen::MENU:
AddButtons(out, l, UiPart::MENU_ITEM, MENU_ITEMS, MENU_COUNT, MENU_Y0, MENU_STEP,
MENU_SIZE);
// «Продолжить» до начала вылазки ведёт в никуда: пункт лучше
// погасить, чем показывать игроку тупик.
out.item[0].enabled = ui.started;
return;
case Screen::PAUSE:
AddButtons(out, l, UiPart::PAUSE_ITEM, PAUSE_ITEMS, PAUSE_COUNT, PAUSE_Y0, PAUSE_STEP,
PAUSE_SIZE);
return;
case Screen::DEFEAT:
AddButtons(out, l, UiPart::DEFEAT_ITEM, DEFEAT_ITEMS, DEFEAT_COUNT, DEFEAT_Y0,
DEFEAT_STEP, DEFEAT_SIZE);
return;
case Screen::SUMMARY:
AddButtons(out, l, UiPart::SUMMARY_ITEM, SUMMARY_ITEMS, SUMMARY_COUNT, SUM_Y0, SUM_STEP,
SUM_SIZE);
return;
case Screen::UPGRADE:
for (int i = 0; i < TREE_NODES; ++i)
{
Widget& w = out.Add(UiPart::UPGRADE_ITEM, i, TreeNodeRect(i));
// Закрытый узел виден, но не нажимается: игрок должен видеть,
// КУДА ведёт ветвь, до того как в неё вложится.
w.enabled = game.profile && game.profile->NodeUnlocked(i);
}
return;
case Screen::DEPTH:
{
// Чекпоинты открываются достигнутой глубиной. Показываются самые
// глубокие: начинать сверху при открытом двадцатом этаже никто не
// будет, а место на экране конечно.
const int total = game.profile ? game.profile->CheckpointCount() : 1;
const int shown = (total < DEPTH_MAX_SHOWN) ? total : DEPTH_MAX_SHOWN;
const int first = total - shown;
for (int i = 0; i < shown; ++i)
{
const int d = Profile::CheckpointDepth(first + i);
static char labels[DEPTH_MAX_SHOWN][16];
std::snprintf(labels[i], sizeof(labels[i]), "DEPTH %d", d);
Widget& wg = out.Add(UiPart::DEPTH_ITEM, d,
Button(l, labels[i], DEPTH_Y0 + i * DEPTH_STEP, DEPTH_SIZE));
wg.text = labels[i];
wg.size = DEPTH_SIZE;
}
return;
}
case Screen::SQUAD:
{
// Карточки только на занятых бойцов: пустой слот — это не боец без
// снаряжения, это никто.
int card = 0;
for (int i = 0; i < NUM_AGENTS; ++i)
{
if (!game.squad.agents[i].present) continue;
Widget& w = out.Add(UiPart::AGENT, i,
UiRect{SQ_CARD_X + (card++) * SQ_CARD_STEP, SQ_CARD_Y,
SQ_CARD_W, SQ_CARD_H});
w.item = game.squad.agents[i].held;
}
const Agent& a = game.squad.agents[ui.agentIndex];
const Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv) return;
const ItemId worn[EQUIP_COUNT] = {inv->weapon, inv->armor, inv->trinket};
for (int i = 0; i < EQUIP_COUNT; ++i)
{
Widget& w = out.Add(EQUIP_PARTS[i], 0,
UiRect{SQ_EQUIP_X, SQ_EQUIP_Y + i * SQ_EQUIP_STEP, SQ_EQUIP_W,
SQ_EQUIP_H});
w.text = EQUIP_LABELS[i];
w.item = worn[i];
}
// Сумка ОБЩАЯ: она не зависит от выбранного бойца, и переложить
// из неё может любой (sim/items.h). Вне вылазки она недоступна:
// сумка принадлежит забегу и очищается при сборке этажа.
for (int s = 0; s < SQUAD_BAG_SLOTS; ++s)
{
Widget& w = out.Add(UiPart::BAG, s, BagRect(s));
w.item = game.squad.bag.slots[s];
w.enabled = ui.started;
}
// Строки характеристик тоже наводимы. Читать «SPEED 95%» и гадать,
// почему 95, — это не информация, а загадка; подсказка отвечает.
for (int i = 0; i < SQ_STAT_LINES; ++i)
out.Add(UiPart::STAT, i,
UiRect{SQ_STAT_X - 3, SQ_STAT_Y + i * SQ_STAT_STEP - 1, 150,
SQ_STAT_STEP});
// Схрон. Ячейки за концом списка тоже интерактивны: именно в них
// предмет КЛАДЁТСЯ, и без них схрон можно было бы только разбирать.
// (продолжение ниже)
if (game.profile)
{
out.Add(UiPart::STASH_SORT, 0,
UiRect{SQ_SORT_X, SQ_STASH_Y - 12, SQ_SORT_W, SQ_SORT_H});
for (int c = 0; c < SQ_STASH_CELLS; ++c)
{
const int k = ui.stashScroll + c;
Widget& w = out.Add(UiPart::STASH, k, StashRect(c));
w.item = (k < game.profile->StashCount()) ? game.profile->stash[size_t(k)]
: ItemInstance{};
}
}
return;
}
case Screen::WORKSHOP:
{
if (!game.profile) return;
for (int c = 0; c < WS_SLOT_CELLS; ++c)
{
const int k = ui.stashScroll + c;
Widget& w = out.Add(UiPart::CRAFT_SLOT, k, CraftSlotRect(c));
w.item = (k < game.profile->StashCount()) ? game.profile->stash[size_t(k)]
: ItemInstance{};
w.enabled = k < game.profile->StashCount();
}
for (int o = 0; o < int(CraftOp::COUNT); ++o)
{
Widget& w = out.Add(UiPart::CRAFT_ITEM, o, CraftOpRect(o));
w.text = CraftOpName(CraftOp(o));
}
// Строки аффиксов на верстаке — тоже кнопки: по ним выбирают ЦЕЛЬ
// направленной операции (sim/craft.h).
if (ui.craftIndex >= 0 && ui.craftIndex < game.profile->StashCount())
{
const ItemInstance& bench = game.profile->stash[size_t(ui.craftIndex)];
for (int a = 0; a < bench.Count(); ++a)
out.Add(UiPart::CRAFT_AFFIX, a,
UiRect{WS_SLOT_X, 198 + a * 9, 210, 9});
}
return;
}
}
}
bool Hovered(const Ui& ui, const Widget& w)
{
return ui.hoverPart == w.part && ui.hoverIndex == w.index;
}
// -----------------------------------------------------------------------------
// Правила инвентаря. Класс предмета должен подходить слоту, и это проверяется
// в ОБЕ стороны: предмет уезжает в новую ячейку, а лежавшее там — в старую.
// Без второй проверки перетаскивание карабина на бинт оставило бы бойца
// с бинтом в руках.
// -----------------------------------------------------------------------------
bool IsSlot(UiPart p)
{
return p == UiPart::HANDS || p == UiPart::ARMOR || p == UiPart::POCKET ||
p == UiPart::BAG || p == UiPart::STASH;
}
// started — вылазка уже идёт. Вне забега сумки НЕТ: она принадлежит вылазке и
// очищается при сборке этажа. Вещь, положенная в неё из меню, исчезла бы вместе
// с миром — молча, как и пропадал пистолет до этой правки.
bool SlotAccepts(UiPart part, ItemId id, bool started = true)
{
if (part == UiPart::BAG && !started) return false;
switch (part)
{
// Пустых рук не бывает: отряд не носит воздух. ItemId::NONE — не оружие,
// поэтому проверка на класс заодно закрывает и этот случай.
case UiPart::HANDS: return IsWeaponItem(id);
case UiPart::ARMOR: return id == ItemId::NONE || ItemDefOf(id).kind == ItemKind::ARMOR;
case UiPart::POCKET: return id == ItemId::NONE || ItemDefOf(id).kind == ItemKind::TRINKET;
case UiPart::BAG: return true; // сумка берёт что угодно, на то она и сумка
case UiPart::STASH: return true; // схрон тем более
default: return false;
}
}
bool CanDrop(UiPart from, ItemId moved, UiPart to, ItemId there, bool started)
{
return SlotAccepts(to, moved, started) && SlotAccepts(from, there, started);
}
// -----------------------------------------------------------------------------
// Сохранить снаряжение бойца В ПРОФИЛЬ.
//
// Без этого сборы перед вылазкой не значили НИЧЕГО. Экран отряда правит живой
// мир (компонент Inventory), а вылазка собирает бойцов заново: NewRun пересоздаёт
// ecs::World и раздаёт снаряжение из профиля (Game::ApplyProfileLoadout). Всё,
// что игрок переложил в меню, стиралось при старте, а вещь, вынутая из схрона,
// исчезала вместе с миром — схрон-то её уже отдал.
//
// Снаружи это выглядело ровно так, как в жалобе: «переодел пистолет первому —
// он выходит с ножом, а пистолет вообще пропал».
//
// Пишем ВСЕГДА, а не только вне забега: посреди вылазки профиль всё равно
// перезапишется при эвакуации, а лишняя запись стоит ничего.
void SaveLoadout(Game& game, int slot)
{
if (!game.profile) return;
if (slot < 0 || slot >= NUM_AGENTS) return;
if (const Inventory* inv = game.world.Get<Inventory>(game.squad.agents[slot].self))
game.profile->loadout[slot] = *inv;
}
// Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на
// ячейку берётся из двух разных мест. Вызывающему это знать незачем: для него
// всё это по-прежнему «слот, в котором лежит вещь».
ItemInstance* SlotPtr(Inventory& inv, SquadBag& bag, UiPart part, int index)
{
switch (part)
{
case UiPart::HANDS: return &inv.weapon;
case UiPart::ARMOR: return &inv.armor;
case UiPart::POCKET: return &inv.trinket;
case UiPart::BAG:
return (index >= 0 && index < SQUAD_BAG_SLOTS) ? &bag.slots[index] : nullptr;
default: return nullptr;
}
}
// Схрон живёт не в Inventory, а в профиле, и ячейка за концом списка — это
// место, куда предмет КЛАДЁТСЯ. Поэтому обмен со схроном разобран отдельно:
// указателя на несуществующую ячейку не бывает.
ItemInstance StashTake(Profile& pr, int index)
{
if (index < 0 || index >= pr.StashCount()) return ItemInstance{};
const ItemInstance out = pr.stash[size_t(index)];
pr.StashRemoveAt(index);
return out;
}
// false — схрон полон и предмет остался на месте. Молча терять его нельзя.
bool StashPut(Profile& pr, int index, const ItemInstance& id)
{
if (id.id == ItemId::NONE) return true;
if (index >= 0 && index < pr.StashCount())
{
pr.stash.insert(pr.stash.begin() + long(index), id);
return int(pr.stash.size()) <= STASH_CAP || (pr.stash.pop_back(), false);
}
return pr.StashPush(id);
}
// Выбросить вещь под ноги бойцу. Без этого решение о нагрузке одностороннее:
// набрал — и тащи, а «что бросить» превращается в «нечего было брать».
//
// Из РУК бросать нельзя: безоружный боец не бежит быстрее, он просто перестаёт
// быть бойцом. Ствол сначала меняют на что-то другое, и только потом бросают.
// Броню и карман — можно: снять плиту, чтобы дойти до лестницы, это ровно то
// решение, ради которого вес и заводился.
void DropToFloor(Ui& ui, Game& game, UiPart part, int index)
{
if (part == UiPart::HANDS || part == UiPart::STASH) return;
Agent& a = game.squad.agents[ui.agentIndex];
Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv) return;
ItemInstance* slot = SlotPtr(*inv, game.squad.bag, part, index);
if (!slot || slot->Empty()) return;
// Задержка подбора обязательна: вещь ложится там же, где стоит боец, и
// без неё тот же шаг поднял бы её обратно.
SpawnFloorItem(game.world, *slot, a.pos, 2.0f);
*slot = ItemInstance{};
SaveLoadout(game, ui.agentIndex);
}
// -----------------------------------------------------------------------------
// Быстрый перенос: SHIFT + клик.
//
// Перетаскивание мышью в кадре 480x270 — это точное попадание в ячейку 74x18
// с зажатой кнопкой, и делать так тридцать раз подряд после каждой вылазки
// невозможно. SHIFT + клик отправляет вещь ТУДА, ГДЕ ЕЙ МЕСТО, и обратно:
//
// схрон -> подходящий слот бойца (что было в слоте, уезжает в схрон);
// слот -> схрон.
//
// Слот выбирается по КЛАССУ вещи, а не по номеру: ствол в руки, плита в броню,
// брелок в карман, расходник в сумку. Это ровно те же правила, что и у
// перетаскивания (SlotAccepts), просто игроку больше не надо их отмерять рукой.
UiPart SlotForItem(ItemId id, bool started)
{
if (IsWeaponItem(id)) return UiPart::HANDS;
switch (ItemDefOf(id).kind)
{
case ItemKind::ARMOR: return UiPart::ARMOR;
case ItemKind::TRINKET: return UiPart::POCKET;
// Расходник — в сумку, но сумка принадлежит ВЫЛАЗКЕ: вне забега её нет,
// и вещь, положенная туда из меню, исчезла бы вместе с миром.
default: return started ? UiPart::BAG : UiPart::NONE;
}
}
// Первая пустая ячейка сумки, иначе последняя. Возвращает -1, если сумки нет.
int FreeBagCell(const SquadBag& bag)
{
for (int i = 0; i < SQUAD_BAG_SLOTS; ++i)
if (bag.slots[i].Empty()) return i;
return -1;
}
void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex);
void QuickMove(Ui& ui, Game& game, UiPart part, int index)
{
if (!game.profile) return;
Agent& a = game.squad.agents[ui.agentIndex];
Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv) return;
Profile& pr = *game.profile;
if (part == UiPart::STASH)
{
if (index < 0 || index >= pr.StashCount()) return;
const ItemInstance moved = pr.stash[size_t(index)];
const UiPart dest = SlotForItem(moved, ui.started);
if (dest == UiPart::NONE) return;
int destIndex = 0;
if (dest == UiPart::BAG)
{
destIndex = FreeBagCell(game.squad.bag);
if (destIndex < 0) return; // сумка полна — молча терять вещь нельзя
}
// Дальше — обычный обмен, тот же самый код, что и у перетаскивания.
// Своя копия правил обмена здесь означала бы два места, где вещь может
// потеряться, вместо одного.
ui.dragFrom = UiPart::STASH;
ui.dragIndex = index;
ui.dragItem = moved;
ApplyDrop(ui, game, dest, destIndex);
ui.dragFrom = UiPart::NONE;
ui.dragIndex = -1;
ui.dragItem = ItemId::NONE;
return;
}
// Обратно в схрон. Из РУК не отправляем: безоружный боец — не боец, и то же
// правило уже стоит на выбрасывании под ноги (DropToFloor).
if (part == UiPart::HANDS || !IsSlot(part)) return;
ItemInstance* slot = SlotPtr(*inv, game.squad.bag, part, index);
if (!slot || slot->Empty()) return;
if (pr.StashCount() >= STASH_CAP) return; // схрон полон
ui.dragFrom = part;
ui.dragIndex = index;
ui.dragItem = *slot;
ApplyDrop(ui, game, UiPart::STASH, pr.StashCount());
ui.dragFrom = UiPart::NONE;
ui.dragIndex = -1;
ui.dragItem = ItemId::NONE;
}
// -----------------------------------------------------------------------------
// Сортировка схрона. Схрон на сорок восемь ячеек, вещи в него валятся в порядке
// подбора, и найти в нём нужное — отдельная работа, которой игрок не просил.
//
// Порядок: класс -> редкость (сначала лучшее) -> число аффиксов -> имя.
// Именно в таком, а не по имени: игрок ищет «во что переодеться», то есть
// сначала вид вещи, а внутри вида — лучшее.
void SortStash(Profile& pr)
{
std::stable_sort(pr.stash.begin(), pr.stash.end(),
[](const ItemInstance& x, const ItemInstance& y) {
const ItemDef dx = ItemDefOf(x.id), dy = ItemDefOf(y.id);
if (dx.kind != dy.kind) return int(dx.kind) < int(dy.kind);
if (x.rarity != y.rarity) return int(x.rarity) > int(y.rarity);
if (x.Count() != y.Count()) return x.Count() > y.Count();
return std::strcmp(dx.name, dy.name) < 0;
});
}
void ApplyDrop(Ui& ui, Game& game, UiPart to, int toIndex)
{
Agent& a = game.squad.agents[ui.agentIndex];
Inventory* inv = game.world.Get<Inventory>(a.self);
if (!inv) return;
const bool fromStash = (ui.dragFrom == UiPart::STASH);
const bool toStash = (to == UiPart::STASH);
if (fromStash || toStash)
{
if (!game.profile) return;
Profile& pr = *game.profile;
if (fromStash && toStash)
{
// Перекладывание внутри схрона: порядок ячеек — единственное, чем
// игрок может им управлять, и ломать его незачем.
const ItemInstance moved = StashTake(pr, ui.dragIndex);
StashPut(pr, toIndex, moved);
return;
}
ItemInstance* slot = SlotPtr(*inv, game.squad.bag, fromStash ? to : ui.dragFrom,
fromStash ? toIndex : ui.dragIndex);
if (!slot) return;
if (fromStash)
{
// Из схрона в слот: то, что лежало в слоте, уезжает в схрон. Если
// схрон переполнен, обмен не состоится целиком — иначе вещь из
// слота исчезла бы.
const ItemInstance taken = pr.stash.empty() ? ItemInstance{} : StashTake(pr, ui.dragIndex);
if (taken.Empty()) return;
const ItemInstance back = *slot;
if (!StashPut(pr, ui.dragIndex, back)) { StashPut(pr, ui.dragIndex, taken); return; }
*slot = taken;
}
else
{
const ItemInstance moved = *slot;
if (moved.Empty()) return;
if (!StashPut(pr, toIndex, moved)) return; // схрон полон
*slot = ItemInstance{};
}
// Руки могли опустеть или наполниться — сущность-оружие обязана
// поменяться вместе с ними.
if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS)
EquipWeapon(game.world, a, inv->weapon);
SaveLoadout(game, ui.agentIndex);
return;
}
ItemInstance* src = SlotPtr(*inv, game.squad.bag, ui.dragFrom, ui.dragIndex);
ItemInstance* dst = SlotPtr(*inv, game.squad.bag, to, toIndex);
if (!src || !dst || src == dst) return;
const ItemInstance moved = *src;
*src = *dst;
*dst = moved;
// Руки поменялись — обязана поменяться и сущность-оружие. Иначе имя новое,
// а статы, класс и правила стрельбы остались от прежнего ствола.
if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS)
EquipWeapon(game.world, a, inv->weapon);
SaveLoadout(game, ui.agentIndex);
}
} // namespace
// -----------------------------------------------------------------------------
// Действия кнопок. Один вход и для клика, и для ENTER: иначе мышь и клавиатура
// начнут делать «почти одно и то же».
//
// Не в анонимном пространстве: этот же вход дёргает `--ui-check` (ui/menu.h).
// -----------------------------------------------------------------------------
void UiActivate(Ui& ui, Game& game, UiPart part, int index)
{
switch (part)
{
case UiPart::UPGRADE_ITEM:
// Покупка молча не проваливается: не хватило опыта — уровень
// остался прежним, и это видно на той же строке.
// Покупка узла дерева. Молча не проваливается: не хватило опыта
// или узел закрыт — уровень остался прежним, и это видно на самом
// узле (sim/tree.h).
if (game.profile) game.profile->BuyNode(index);
return;
case UiPart::MENU_ITEM:
switch (index)
{
case 0: if (ui.started) ui.screen = Screen::GAME; break;
case 1:
// Вылазка начинается с выбора глубины: с открытых
// чекпоинтов. Это и есть ответ на «а если этажей десять
// тысяч» — проходить их заново каждый раз не нужно.
ui.depthIndex = 0;
ui.screen = Screen::DEPTH;
break;
case 2: ui.screen = Screen::SQUAD; break;
case 3: ui.screen = Screen::UPGRADE; break;
case 4: ui.screen = Screen::WORKSHOP; break;
// QUIT — последний пункт, и попасть сюда можно только им.
// Раньше здесь стоял default, и любой НЕ перечисленный пункт
// выходил из игры: экран улучшений появился четвёртым, а
// обработчик о нём не узнал — нажатие UPGRADES закрывало игру,
// и снаружи это выглядело как падение.
case 5: ui.quit = true; break;
default: break;
}
return;
case UiPart::PAUSE_ITEM:
if (index == 0) ui.screen = Screen::GAME;
else if (index == 1) ui.screen = Screen::SQUAD;
else ui.screen = Screen::MENU;
return;
case UiPart::DEPTH_ITEM:
game.combatMode = true;
game.NewRun(index); // index — глубина чекпоинта, а не номер кнопки
ui.worldReset = true;
ui.started = true;
ui.screen = Screen::GAME;
return;
case UiPart::STASH_SORT:
if (game.profile) SortStash(*game.profile);
ui.stashScroll = 0;
return;
case UiPart::HUD_BUTTON:
switch (index)
{
case 0: ui.screen = Screen::SQUAD; break;
case 1: ui.screen = Screen::UPGRADE; break;
case 2: ui.screen = Screen::WORKSHOP; break;
default: ui.screen = Screen::PAUSE; break;
}
return;
case UiPart::CRAFT_SLOT:
// Клик по ячейке схрона: обычный — положить вещь на верстак,
// повторный по той же — назначить её ДОНОРОМ для отпечатка.
// Второй клик, а не второй список: экран и так плотный.
if (index == ui.craftIndex && ui.craftDonor != index) ui.craftDonor = index;
else
{
ui.craftIndex = index;
ui.craftAffix = 0;
if (ui.craftDonor == index) ui.craftDonor = -1;
}
ui.craftMessage = 0;
return;
case UiPart::CRAFT_AFFIX:
// Выбор цели направленной операции. Ничего не тратит.
ui.craftAffix = index;
ui.craftMessage = 0;
return;
case UiPart::CRAFT_ITEM:
{
if (!game.profile) return;
Profile& pr = *game.profile;
if (ui.craftIndex < 0 || ui.craftIndex >= pr.StashCount()) return;
// Сид от состояния профиля: два одинаковых клика подряд не дают
// один и тот же бросок, но и от системного времени игра не зависит
// — прогоны обязаны повторяться.
uint32_t rng = uint32_t(pr.runs * 7919 + pr.StashCount() * 104729 +
int(pr.bankedXp) + ui.craftIndex * 31 + index * 17);
// Донор обязан быть ДРУГОЙ вещью: переплавить предмет в самого
// себя нельзя, и молча брать «первый попавшийся» тоже.
ItemInstance* donor = nullptr;
if (CraftNeedsDonor(CraftOp(index)) && ui.craftDonor >= 0 &&
ui.craftDonor < pr.StashCount() && ui.craftDonor != ui.craftIndex)
donor = &pr.stash[size_t(ui.craftDonor)];
ItemInstance& it = pr.stash[size_t(ui.craftIndex)];
const CraftResult r = ApplyCraft(CraftOp(index), it, pr.material, pr.maxDepth, rng,
ui.craftAffix, donor);
// Сгоревший донор убирается из схрона: пустая ячейка посреди
// списка читалась бы как ошибка, а не как цена операции.
if (r == CraftResult::OK && donor && donor->Empty())
{
pr.StashRemoveAt(ui.craftDonor);
if (ui.craftIndex > ui.craftDonor) --ui.craftIndex;
ui.craftDonor = -1;
}
// Рассыпавшаяся вещь убирается из схрона: пустая ячейка посреди
// списка выглядела бы как ошибка, а не как последствие риска.
if (r == CraftResult::DESTROYED)
{
pr.StashRemoveAt(ui.craftIndex);
if (ui.craftIndex >= pr.StashCount()) ui.craftIndex = pr.StashCount() - 1;
if (ui.craftIndex < 0) ui.craftIndex = 0;
}
ui.craftMessage = int(r) + 1;
ui.craftOpDone = index;
ui.craftFlash = 3.0f;
return;
}
case UiPart::SUMMARY_ITEM:
// Забег кончился, мира больше нет: «продолжить» ведёт только в меню.
ui.started = false;
ui.screen = Screen::MENU;
return;
case UiPart::DEFEAT_ITEM:
if (index == 0)
{
game.Reset();
ui.worldReset = true;
ui.screen = Screen::GAME;
}
else
ui.screen = Screen::MENU;
return;
default:
return;
}
}
namespace
{
bool Pressed(int key) { return IsKeyPressed(key); }
// Все списки листаются одинаково: стрелки и WS.
int Step()
{
int d = 0;
if (Pressed(KEY_DOWN) || Pressed(KEY_S)) d += 1;
if (Pressed(KEY_UP) || Pressed(KEY_W)) d -= 1;
return d;
}
} // namespace
void UiApplyDropForTest(Ui& ui, Game& game, UiPart to, int toIndex)
{
ApplyDrop(ui, game, to, toIndex);
}
void UiQuickMoveForTest(Ui& ui, Game& game, UiPart part, int index)
{
QuickMove(ui, game, part, index);
}
int UiMenuItemCount() { return MENU_COUNT; }
const char* UiMenuItemName(int index)
{
return (index >= 0 && index < MENU_COUNT) ? MENU_ITEMS[index] : "?";
}
// -----------------------------------------------------------------------------
// Ввод
// -----------------------------------------------------------------------------
bool Ui::Update(Game& game, const ScreenRect& fit, float dt)
{
blink += dt;
const Layout l = Of(fit);
// Окно -> кадр: обратная ScreenFitRect. Вне кадра курсор висит на чёрных
// полях, где рисовать нечего, — значит и наведения там нет.
const Vector2 m = GetMousePosition();
mx = (m.x - float(l.x)) / float(l.u);
my = (m.y - float(l.y)) / float(l.u);
mouseIn = mx >= 0.0f && my >= 0.0f && mx < float(l.fw) && my < float(l.fh);
// Экран сменился на лету (ESC из отряда) — предмет с курсора возвращается
// сам: тащить его больше некуда.
if (screen != Screen::SQUAD) dragging = false;
Widgets w;
BuildWidgets(*this, game, l, w);
const Widget* hot = mouseIn ? w.At(mx, my) : nullptr;
hoverPart = hot ? hot->part : UiPart::NONE;
hoverIndex = hot ? hot->index : -1;
const bool moved = GetMouseDelta().x != 0.0f || GetMouseDelta().y != 0.0f;
const bool lmbDown = IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && mouseIn;
const bool lmbUp = IsMouseButtonReleased(MOUSE_BUTTON_LEFT);
// Системная стрелка поверх кадра, растянутого в три-четыре раза, выглядит
// чужой вещью, поэтому внутри кадра курсор рисуем свой. На чёрных полях
// системный нужен обратно: там мышь снова принадлежит окну, а не игре.
if (mouseIn != cursorHidden)
{
cursorHidden = mouseIn;
if (mouseIn) HideCursor();
else ShowCursor();
}
switch (screen)
{
case Screen::GAME:
// Клик по кнопке панели забирает ввод себе: иначе он ушёл бы в игру
// и отряд дёрнулся бы к курсору в момент открытия экрана.
if (lmbDown && hot && hot->part == UiPart::HUD_BUTTON)
{
UiActivate(*this, game, hot->part, hot->index);
return true;
}
if (Pressed(KEY_ESCAPE)) { screen = Screen::PAUSE; return true; }
if (Pressed(KEY_U)) { screen = Screen::UPGRADE; return true; }
// Только I: TAB в бою переключает цель (core/input.h), и отбирать
// его под инвентарь нельзя.
if (Pressed(KEY_I)) { screen = Screen::SQUAD; return true; }
// Отряд полёг — бой закончился, и решать это должен не игрок.
// Забег закрывается ЗДЕСЬ, до показа итога: вынесенного нет,
// снаряжение сбрасывается, чекпоинт остаётся.
if (game.SquadWiped())
{
if (!game.RunOver()) game.WipeRun();
screen = Screen::SUMMARY;
return true;
}
return false;
case Screen::MENU:
{
// Наведение ведёт выбор за собой: иначе после мыши клавиатура
// прыгала бы с пункта, на который игрок уже не смотрит.
if (moved && hot && hot->enabled) menuIndex = hot->index;
const int d = Step();
if (d) menuIndex = (menuIndex + d + MENU_COUNT) % MENU_COUNT;
if (menuIndex == 0 && !started && d >= 0) menuIndex = 1;
if (lmbDown && hot && hot->enabled)
UiActivate(*this, game, hot->part, hot->index);
else if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_KP_ENTER))
UiActivate(*this, game, UiPart::MENU_ITEM, menuIndex);
return true;
}
case Screen::PAUSE:
if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ESCAPE)) { screen = Screen::GAME; return true; }
if (Pressed(KEY_I) || Pressed(KEY_TAB)) { screen = Screen::SQUAD; return true; }
if (Pressed(KEY_Q)) { screen = Screen::MENU; return true; }
return true;
case Screen::DEFEAT:
if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE))
UiActivate(*this, game, UiPart::DEFEAT_ITEM, 0);
if (Pressed(KEY_ESCAPE)) screen = Screen::MENU;
return true;
case Screen::SUMMARY:
if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_ESCAPE))
UiActivate(*this, game, UiPart::SUMMARY_ITEM, 0);
return true;
case Screen::UPGRADE:
if (lmbDown && hot) UiActivate(*this, game, hot->part, hot->index);
if (Pressed(KEY_ESCAPE) || Pressed(KEY_U)) screen = Screen::MENU;
return true;
case Screen::WORKSHOP:
craftFlash = std::max(0.0f, craftFlash - dt);
if (craftFlash <= 0.0f) craftMessage = 0;
if (lmbDown && hot && hot->enabled) UiActivate(*this, game, hot->part, hot->index);
if (Pressed(KEY_Q)) stashScroll = std::max(0, stashScroll - WS_SLOT_COLS);
if (Pressed(KEY_E)) stashScroll += WS_SLOT_COLS;
if (game.profile)
{
const int cap = std::max(0, game.profile->StashCount() - WS_SLOT_CELLS);
stashScroll = std::min(stashScroll, cap);
craftIndex = std::min(craftIndex, std::max(0, game.profile->StashCount() - 1));
}
if (Pressed(KEY_ESCAPE)) screen = Screen::MENU;
return true;
case Screen::DEPTH:
{
const int shown = w.count;
if (shown <= 0) { screen = Screen::MENU; return true; }
if (moved && hot) depthIndex = -1; // мышь ведёт выбор, а не индекс
const int d = Step();
if (d) depthIndex = (depthIndex + d + shown) % shown;
if (depthIndex < 0) depthIndex = 0;
if (lmbDown && hot) { UiActivate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE))
{
const Widget& pick = w.item[depthIndex];
UiActivate(*this, game, pick.part, pick.index);
return true;
}
if (Pressed(KEY_ESCAPE)) screen = Screen::MENU;
return true;
}
case Screen::SQUAD:
{
// --- мышь: выбор бойца и перетаскивание предметов ---
if (lmbDown && hot)
{
// SHIFT + клик — быстрый перенос: вещь уходит туда, где ей
// место, без прицеливания мышью в ячейку 74x18.
const bool quick = IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT);
if (quick && IsSlot(hot->part) && hot->item != ItemId::NONE)
{
QuickMove(*this, game, hot->part, hot->index);
return true;
}
if (hot->part == UiPart::STASH_SORT)
{
UiActivate(*this, game, hot->part, hot->index);
return true;
}
if (hot->part == UiPart::AGENT)
{
agentIndex = hot->index;
}
else if (IsSlot(hot->part))
{
// Клавиатура и мышь смотрят на одну ячейку: после клика
// ENTER сработает именно по ней.
if (hot->part == UiPart::BAG) slotIndex = hot->index;
if (hot->item != ItemId::NONE)
{
dragging = true;
dragFrom = hot->part;
dragIndex = hot->index;
dragItem = hot->item;
}
}
}
// Прокрутка схрона. Q/E, а не колесо: колесо на экране отряда
// ничего не значит, а руки уже лежат на клавишах выбора бойца.
if (game.profile)
{
const int last = game.profile->StashCount() - SQ_STASH_CELLS;
if (Pressed(KEY_E) && stashScroll < last) ++stashScroll;
if (Pressed(KEY_Q) && stashScroll > 0) --stashScroll;
if (stashScroll > last) stashScroll = (last > 0) ? last : 0;
}
// Правая кнопка — отказ от переноса: предмет возвращается на место.
if (dragging && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) dragging = false;
// Она же по занятой ячейке, когда ничего не тащат, — выбросить.
else if (!dragging && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && hot &&
IsSlot(hot->part) && hot->item != ItemId::NONE)
DropToFloor(*this, game, hot->part, hot->index);
// Примет ли слот под курсором то, что тащим. Считается КАЖДЫЙ кадр,
// пока предмет на курсоре: по этому же флагу ячейка красится в
// «нельзя» (DrawCell).
//
// Раньше флаг не выставлялся НИГДЕ — только сбрасывался. То есть
// перетаскивание мышью не работало вообще: сброс молча отменялся, и
// единственным рабочим способом переодеться оставался ENTER. Это и
// была вторая половина жалобы «пистолет пропадает»: игрок тащил его
// в руки, отпускал, и не происходило ничего.
if (dragging)
{
const ItemInstance there = hot ? hot->item : ItemInstance{};
dropOk = hot && IsSlot(hot->part) &&
CanDrop(dragFrom, dragItem, hot->part, there, started);
}
if (dragging && lmbUp)
{
if (dropOk && hot) ApplyDrop(*this, game, hot->part, hot->index);
dragging = false;
}
if (!dragging)
{
dragFrom = UiPart::NONE;
dragIndex = -1;
dragItem = ItemId::NONE;
dropOk = false;
}
// --- клавиатура ---
if (Pressed(KEY_ESCAPE) || Pressed(KEY_I) || Pressed(KEY_TAB))
{
screen = started ? Screen::GAME : Screen::MENU;
dragging = false;
return true;
}
const int d = Step();
if (d) slotIndex = (slotIndex + d + SQUAD_BAG_SLOTS) % SQUAD_BAG_SLOTS;
// Пока предмет висит на курсоре, отряд не листается: взят он был
// из инвентаря этого бойца, а лёг бы в инвентарь другого.
if (!dragging)
{
// Листается только по ЗАНЯТЫМ слотам: пустые пролистываются
// насквозь, иначе половина нажатий показывала бы пустоту.
const int step = (Pressed(KEY_RIGHT) || Pressed(KEY_D)) ? 1
: (Pressed(KEY_LEFT) || Pressed(KEY_A)) ? -1
: 0;
if (step != 0)
for (int k = 0; k < NUM_AGENTS; ++k)
{
agentIndex = (agentIndex + step + NUM_AGENTS) % NUM_AGENTS;
if (game.squad.agents[agentIndex].present) break;
}
}
// ENTER — взять из сумки в руки. Обратно уезжает то, что было:
// отряд не носит воздух, у него всегда что-то в руках.
if (Pressed(KEY_ENTER) || Pressed(KEY_KP_ENTER))
{
// Взять из ОБЩЕЙ сумки в руки выбранному бойцу, а бывшее в
// руках положить на его место. Разрешено только оружие:
// бинтом отбиваться нечем.
Agent& a = game.squad.agents[agentIndex];
Inventory* inv = game.world.Get<Inventory>(a.self);
ItemInstance& cell = game.squad.bag.slots[slotIndex];
if (inv && slotIndex >= 0 && slotIndex < SQUAD_BAG_SLOTS && IsWeaponItem(cell))
{
std::swap(cell, inv->weapon);
EquipWeapon(game.world, a, inv->weapon);
SaveLoadout(game, agentIndex);
}
}
return true;
}
}
return true;
}
// -----------------------------------------------------------------------------
// Рисование
// -----------------------------------------------------------------------------
namespace
{
void DrawButton(const Layout& l, const Ui& ui, const Widget& b, bool selected)
{
const bool hot = Hovered(ui, b);
const Color c = !b.enabled ? COL_DEAD : ((hot || selected) ? COL_PICK : COL_FAINT);
if (b.enabled && hot) Box(l, b.rect, COL_HOVER);
if (b.enabled && (hot || selected))
Box(l, UiRect{b.rect.x, b.rect.y + b.rect.h - 1, b.rect.w, 1}, COL_BLOOD);
Text(l, b.text, b.rect.x + BTN_PAD_X, b.rect.y + BTN_PAD_Y, b.size, c);
}
// Предмет в ячейке: цветной мазок и имя. Цвет берётся из каталога, чтобы
// вещь узнавалась быстрее, чем читается слово.
void DrawItem(const Layout& l, const ItemInstance& it, int x, int y, int h, bool bright)
{
if (it.Empty())
{
Text(l, "-", x + 2, y + h / 2 - 4, 8, COL_FAINT);
return;
}
const ItemDef d = ItemDefOf(it.id);
Box(l, UiRect{x, y + h / 2 - 4, 8, 8}, Color{d.r, d.g, d.b, 255});
// Имя красится по РЕДКОСТИ, а мазок остаётся цветом вида. Так вещь
// по-прежнему узнаётся быстрее, чем читается, но «это реликт» видно, не
// наводя курсор: иначе редкость приходилось бы искать, а её смысл в том,
// чтобы бросаться в глаза среди шестнадцати ячеек.
Color name = bright ? COL_TEXT : COL_FAINT;
if (it.rarity != Rarity::COMMON)
{
const RarityColor rc = RarityColorOf(it.rarity);
const unsigned char k = bright ? 255 : 190;
name = Color{(unsigned char)(rc.r * k / 255), (unsigned char)(rc.g * k / 255),
(unsigned char)(rc.b * k / 255), 255};
}
Text(l, d.name, x + 12, y + h / 2 - 4, 8, name);
}
// Ячейка инвентаря. Рисуется строго по прямоугольнику из таблицы — тому же,
// по которому мышь проверяет попадание.
void DrawCell(const Layout& l, const Ui& ui, const Widget& c, bool selected)
{
const bool hot = Hovered(ui, c);
// Пока предмет висит на курсоре, ячейка честно говорит, примет ли она его.
const bool bad = hot && ui.dragging && !ui.dropOk;
if (bad) Box(l, c.rect, COL_REJECT);
else if (hot) Box(l, c.rect, COL_HOVER);
BoxLines(l, c.rect, bad ? COL_BLOOD : (hot ? COL_TEXT : (selected ? COL_PICK : COL_EDGE)));
int tx = c.rect.x + 5;
if (c.text)
{
Text(l, c.text, tx, c.rect.y + c.rect.h / 2 - 3, 7, COL_FAINT);
tx += SQ_LABEL_W;
}
// То, что сейчас на курсоре, из своей ячейки убрано: иначе предмет
// выглядел бы лежащим в двух местах сразу.
const bool taken = ui.dragging && ui.dragFrom == c.part && ui.dragIndex == c.index;
DrawItem(l, taken ? ItemInstance{} : c.item, tx, c.rect.y, c.rect.h, hot || selected);
}
void DrawSquadScreen(const Layout& l, const Game& game, const Ui& ui, const Widgets& w)
{
Shade(l, {4, 4, 6, 240});
TextCentered(l, "SQUAD", 12, 16, COL_TEXT);
Rule(l, 32, 60);
for (int i = 0; i < w.count; ++i)
{
const Widget& c = w.item[i];
if (c.part == UiPart::AGENT)
{
const Agent& a = game.squad.agents[c.index];
const bool sel = (c.index == ui.agentIndex);
const bool hot = Hovered(ui, c);
if (hot) Box(l, c.rect, COL_HOVER);
BoxLines(l, c.rect, sel ? COL_PICK : COL_EDGE);
char buf[8];
std::snprintf(buf, sizeof(buf), "%d", c.index + 1);
Text(l, buf, c.rect.x + 6, c.rect.y + 4, 10, sel ? COL_PICK : COL_FAINT);
// Номер бойца читается по цвету слота и больше ничем — тот же цвет,
// что у силуэта в бою.
const uint32_t sc = SlotColor(c.index);
Box(l, UiRect{c.rect.x + 6, c.rect.y + 18, 12, 3},
Color{uint8_t(sc & 0xFF), uint8_t((sc >> 8) & 0xFF), uint8_t((sc >> 16) & 0xFF),
uint8_t(a.alive ? 255 : 90)});
Text(l, a.alive ? ItemName(a.held) : "DOWN", c.rect.x + 22, c.rect.y + 17, 8,
a.alive ? (sel ? COL_TEXT : COL_FAINT) : COL_BLOOD);
}
else if (IsSlot(c.part))
{
DrawCell(l, ui, c, c.part == UiPart::BAG && c.index == ui.slotIndex);
}
}
// Подпись честно говорит, почему сумка не берёт вещи вне забега. Молчащий
// отказ читается как поломка — а это правило: сумка принадлежит вылазке.
if (ui.started) Text(l, "BAG", SQ_BAG_X, SQ_BAG_Y - 14, 10, COL_TEXT);
else Text(l, "BAG - RAID ONLY", SQ_BAG_X, SQ_BAG_Y - 14, 8, COL_DEAD);
if (game.profile)
{
char cap[32];
std::snprintf(cap, sizeof(cap), "STASH %d/%d", game.profile->StashCount(), STASH_CAP);
Text(l, cap, SQ_STASH_X, SQ_STASH_Y - 12, 10, COL_TEXT);
if (const Widget* sortBtn = w.Find(UiPart::STASH_SORT, 0))
{
const bool hot = Hovered(ui, *sortBtn);
BoxLines(l, sortBtn->rect, hot ? COL_TEXT : COL_EDGE);
Text(l, "SORT", sortBtn->rect.x + 5, sortBtn->rect.y + 1, 8,
hot ? COL_TEXT : COL_FAINT);
}
// Схрон длиннее сетки: без явной подсказки прокрутка не существует.
if (game.profile->StashCount() > SQ_STASH_CELLS)
Text(l, "Q/E SCROLL", SQ_STASH_X + 92, SQ_STASH_Y - 11, 8, COL_FAINT);
}
// Числа те же, что читает симуляция, — иначе экран отряда врал бы.
const Agent& a = game.squad.agents[ui.agentIndex];
char line[64];
std::snprintf(line, sizeof(line), "HP %d/%d", int(a.hp + 0.5f), int(a.maxHp + 0.5f));
Text(l, line, SQ_STAT_X, SQ_STAT_Y, 8, COL_FAINT);
std::snprintf(line, sizeof(line), "ARMOR %d%%", int(a.armor * 100.0f + 0.5f));
Text(l, line, SQ_STAT_X, SQ_STAT_Y + SQ_STAT_STEP, 8, COL_FAINT);
// Скорость и нагрузка — ОДНОЙ строкой, потому что это одно решение:
// цифра SPEED уже включает в себя перегруз, и без веса рядом она выглядит
// необъяснимой. Отдельной строкой ниже LOAD не влезала — упиралась в
// заголовок схрона, и это было видно только на снятом кадре.
std::snprintf(line, sizeof(line), "SPEED %d%% LOAD %.1f/%.0f", int(a.speedMul * 100.0f + 0.5f),
double(a.load), double(CARRY_LIMIT));
Text(l, line, SQ_STAT_X, SQ_STAT_Y + SQ_STAT_STEP * 2, 8,
(a.load > CARRY_LIMIT) ? COL_BLOOD : COL_FAINT);
std::snprintf(line, sizeof(line), "SIGHT %d%%", int(a.sightMul * 100.0f + 0.5f));
Text(l, line, SQ_STAT_X, SQ_STAT_Y + SQ_STAT_STEP * 3, 8, COL_FAINT);
Text(l, "SHIFT+CLICK MOVES ITEM DRAG ALSO WORKS RMB DROPS", SQ_STAT_X, SQ_HINT_Y, 8,
COL_FAINT);
Text(l, "A/D FIGHTER W/S SLOT ENTER TAKE ESC BACK", SQ_STAT_X,
SQ_HINT_Y + SQ_HINT_STEP, 8, COL_FAINT);
}
// Предмет под курсором: его видно ровно там, где мышь, иначе перенос читается
// как «щёлкнул и вещь телепортировалась».
void DrawDragged(const Layout& l, const Ui& ui)
{
if (!ui.dragging || ui.dragItem == ItemId::NONE) return;
const ItemDef d = ItemDefOf(ui.dragItem);
const int x = int(ui.mx) + 7;
const int y = int(ui.my) + 7;
const int w = TextW(l, d.name, 8) + 18;
Box(l, UiRect{x, y, w, 14}, COL_SHADOW);
BoxLines(l, UiRect{x, y, w, 14}, ui.dropOk ? COL_PICK : COL_BLOOD);
Box(l, UiRect{x + 3, y + 3, 8, 8}, Color{d.r, d.g, d.b, 255});
Text(l, d.name, x + 15, y + 3, 8, COL_TEXT);
}
// Свой курсор: ряды пикселей КАДРА, а не системная стрелка. Сначала контур
// на пиксель шире, потом светлая заливка — так остриё видно и на светлом фоне.
void DrawPointer(const Layout& l, const Ui& ui)
{
constexpr int H = 9; // высота стрелки в пикселях кадра
const int px = int(ui.mx);
const int py = int(ui.my);
for (int r = 0; r <= H; ++r) Box(l, UiRect{px - 1, py - 1 + r, r + 3, 1}, COL_SHADOW);
for (int r = 0; r < H; ++r) Box(l, UiRect{px, py + r, r + 1, 1}, COL_PICK);
}
} // namespace
void Ui::Draw(const Game& game, const ScreenRect& fit) const
{
// В бою меню раньше не рисовалось вовсе. Теперь рисуется — но только нижняя
// панель кнопок: она часть боевого экрана, а не «меню поверх игры».
const Layout l = Of(fit);
const bool pulse = (int(blink * 2.0f) & 1) == 0;
Widgets w;
BuildWidgets(*this, game, l, w);
switch (screen)
{
case Screen::GAME:
{
// Панель рисуется поверх готового кадра, как и всё остальное меню:
// сам мир в софтверном фреймбуфере про неё не знает.
for (int i = 0; i < w.count; ++i)
{
const Widget& b = w.item[i];
if (b.part != UiPart::HUD_BUTTON) continue;
const bool hot = (hoverPart == b.part && hoverIndex == b.index);
Box(l, b.rect, hot ? Color{40, 34, 30, 220} : Color{12, 12, 14, 190});
BoxLines(l, b.rect, hot ? COL_PICK : COL_EDGE);
Text(l, b.text, b.rect.x + 4, b.rect.y + 3, 7, hot ? COL_TEXT : COL_FAINT);
}
break;
}
case Screen::MENU:
Shade(l, COL_DIM);
TextCentered(l, "SQUAD PROTOTYPE", 52, 20, COL_TEXT);
Rule(l, 80, 78);
TextCentered(l, "IT IS DARK AND THEY ARE CLOSE", 88, 8, COL_FAINT);
for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], i == menuIndex);
TextCentered(l, "MOUSE OR W/S SELECT LMB OR ENTER CONFIRM", 236, 8, COL_FAINT);
break;
case Screen::PAUSE:
Shade(l, {6, 6, 8, 170});
TextCentered(l, "PAUSED", 92, 18, COL_TEXT);
Rule(l, 118, 44);
for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], false);
TextCentered(l, "CLICK OR ESC RESUME TAB SQUAD Q MENU", 232, 8, COL_FAINT);
break;
case Screen::DEFEAT:
{
Shade(l, {12, 2, 2, 226});
TextCentered(l, "SQUAD LOST", 96, 22, COL_BLOOD);
Rule(l, 126, 60);
char line[64];
std::snprintf(line, sizeof(line), "KILLS %d SHOTS %d", game.metrics.targetsKilled,
game.metrics.shotsFired);
TextCentered(l, line, 142, 9, COL_FAINT);
// Пульсирует подсветка, а не сама кнопка: мигающую цель мышью
// не поймать.
for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], i == 0 && pulse);
TextCentered(l, "CLICK OR ENTER TRY AGAIN ESC MENU", 232, 8, COL_FAINT);
break;
}
case Screen::UPGRADE:
{
Shade(l, COL_DIM);
TextCentered(l, "UPGRADE TREE", 12, 14, COL_TEXT);
Rule(l, 28, 60);
char line[96];
const float banked = game.profile ? game.profile->bankedXp : 0.0f;
std::snprintf(line, sizeof(line), "BANKED XP %d", int(banked));
TextCentered(l, line, 34, 8, COL_FAINT);
// Заголовки ветвей.
for (int b = 0; b < BRANCH_COUNT; ++b)
{
const UiRect r = TreeNodeRect(b * NODES_PER_BRANCH);
Text(l, BranchName(Branch(b)), r.x + 2, TR_Y - 10, 8, COL_PICK);
}
// Связи между узлами ветви: тонкая линия сверху вниз. Она и есть
// «цепочка» — узел открывается предыдущим.
for (int i = 0; i < TREE_NODES; ++i)
{
const int row = i % NODES_PER_BRANCH;
if (row == 0) continue;
const UiRect a = TreeNodeRect(i - 1);
const bool open = game.profile && game.profile->NodeRank(i - 1) > 0;
Box(l, UiRect{a.x + TR_NODE_W / 2, a.y + TR_NODE_H, 1, TR_ROW_H - TR_NODE_H},
open ? Color{150, 130, 90, 255} : COL_DEAD);
}
for (int i = 0; i < w.count; ++i)
{
const Widget& c = w.item[i];
if (c.part != UiPart::UPGRADE_ITEM) continue;
const TreeNode& n = TreeNodeAt(c.index);
const int rank = game.profile ? game.profile->NodeRank(c.index) : 0;
const bool maxed = rank >= n.maxRank;
const bool afford = game.profile && game.profile->CanBuyNode(c.index);
const bool hover = (hoverPart == c.part && hoverIndex == c.index);
if (hover && afford) Box(l, c.rect, COL_HOVER);
// Взятый узел заливается: сетка должна читаться одним взглядом,
// без вчитывания в цифры уровней.
if (rank > 0) Box(l, c.rect, Color{58, 46, 26, 200});
BoxLines(l, c.rect, !c.enabled ? COL_DEAD
: (afford ? Color{150, 130, 90, 255} : COL_EDGE));
Text(l, n.name, c.rect.x + 3, c.rect.y + 2, 7,
c.enabled ? (rank > 0 ? COL_TEXT : COL_FAINT) : COL_DEAD);
// Уровни точками: пять кружков читаются быстрее, чем «3/5».
for (int k = 0; k < n.maxRank; ++k)
Box(l, UiRect{c.rect.x + 3 + k * 5, c.rect.y + 12, 3, 3},
(k < rank) ? Color{210, 180, 110, 255} : COL_DEAD);
if (maxed) std::snprintf(line, sizeof(line), "MAX");
else std::snprintf(line, sizeof(line), "%d",
int(TreeNodeCost(c.index, rank,
game.profile ? game.profile->TreeRanks() : 0)));
Text(l, line, c.rect.x + TR_NODE_W - 3 - TextW(l, line, 7), c.rect.y + 11, 7,
maxed ? COL_FAINT : (afford ? COL_PICK : COL_DEAD));
}
// Подсказка по узлу под курсором: что он даёт и сколько уже дал.
if (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex >= 0 && hoverIndex < TREE_NODES)
{
const TreeNode& n = TreeNodeAt(hoverIndex);
const int rank = game.profile ? game.profile->NodeRank(hoverIndex) : 0;
const bool percent = n.perRank < 1.0f;
if (percent)
std::snprintf(line, sizeof(line), "%s: %+d%% PER RANK, NOW %+d%%",
NodeEffectName(n.effect), int(n.perRank * 100.0f + 0.5f),
int(n.perRank * float(rank) * 100.0f + 0.5f));
else
std::snprintf(line, sizeof(line), "%s: %+d PER RANK, NOW %+d",
NodeEffectName(n.effect), int(n.perRank + 0.5f),
int(n.perRank * float(rank) + 0.5f));
TextCentered(l, line, 252, 8, COL_TEXT);
}
TextCentered(l, "CLICK TO SPEND ESC BACK", 262, 7, COL_FAINT);
break;
}
case Screen::WORKSHOP:
{
Shade(l, COL_DIM);
TextCentered(l, "WORKSHOP", 12, 16, COL_TEXT);
Rule(l, 32, 56);
char line[96];
// Материалы строкой сверху. Они валюта, и место им там же, где у
// улучшений стоит запас опыта: игрок читает «на что мне хватает»
// до того, как выбирает вещь.
if (game.profile)
{
const Profile& pr = *game.profile;
std::snprintf(line, sizeof(line), "%d SCRAP %d BIO %d CRYO %d SHARD %d SLAG",
pr.material[0], pr.material[1], pr.material[2], pr.material[3],
pr.material[4]);
TextCentered(l, line, 40, 8, COL_FAINT);
}
Text(l, "STASH (CLICK TWICE = DONOR)", WS_SLOT_X, 60, 8, COL_FAINT);
Text(l, "BENCH", WS_OP_X, 60, 8, COL_FAINT);
// --- ячейки схрона ---
for (int i = 0; i < w.count; ++i)
{
const Widget& c = w.item[i];
if (c.part != UiPart::CRAFT_SLOT) continue;
DrawCell(l, *this, c, c.index == craftIndex);
// Донор помечается кровью: он сгорит, и увидеть это надо ДО
// нажатия, а не после.
if (c.index == craftDonor)
Box(l, UiRect{c.rect.x + c.rect.w - 5, c.rect.y + 2, 3, 3}, COL_BLOOD);
}
// --- что лежит на верстаке ---
const ItemInstance* bench = nullptr;
if (game.profile && craftIndex >= 0 && craftIndex < game.profile->StashCount())
bench = &game.profile->stash[size_t(craftIndex)];
if (!bench)
{
Text(l, "STASH IS EMPTY", WS_OP_X, 74, 9, COL_FAINT);
}
else
{
const ItemDef d = ItemDefOf(bench->id);
const RarityColor rc = RarityColorOf(bench->rarity);
std::snprintf(line, sizeof(line), "%s %s", RarityName(bench->rarity), d.name);
Text(l, line, WS_OP_X, 72, 10, Color{rc.r, rc.g, rc.b, 255});
// Аффиксы столбиком слева от операций: игрок должен видеть, что
// именно он собирается менять, не наводя курсор.
for (int a = 0; a < bench->Count(); ++a)
{
char af[64];
AffixLabel(bench->affix[a], af, int(sizeof(af)));
// Выбранная строка подсвечена: направленные операции
// работают именно по ней, и промах тут стоит материалов.
const bool picked = (a == craftAffix);
const bool hot = (hoverPart == UiPart::CRAFT_AFFIX && hoverIndex == a);
if (picked) Box(l, UiRect{WS_SLOT_X - 2, 198 + a * 9, 212, 9}, COL_HOVER);
Text(l, picked ? ">" : " ", WS_SLOT_X - 6, 200 + a * 9, 8, COL_PICK);
Text(l, af, WS_SLOT_X, 200 + a * 9, 8,
(picked || hot) ? COL_TEXT : COL_FAINT);
}
if (bench->Count() == 0)
Text(l, "NO AFFIXES", WS_SLOT_X, 200, 8, COL_DEAD);
}
// --- операции ---
for (int o = 0; o < int(CraftOp::COUNT); ++o)
{
const CraftOp op = CraftOp(o);
const UiRect r = CraftOpRect(o);
const bool hover = (hoverPart == UiPart::CRAFT_ITEM && hoverIndex == o);
bool afford = false;
CraftCost cost;
if (bench && game.profile)
{
cost = CostOf(op, *bench);
afford = true;
for (int m = 0; m < MATERIAL_COUNT; ++m)
if (game.profile->material[m] < cost.amount[m]) afford = false;
}
if (hover && afford) Box(l, r, COL_HOVER);
BoxLines(l, r, afford ? COL_EDGE : COL_DEAD);
// Залом красится кровью всегда: единственная операция, которая
// может уничтожить вещь, обязана выглядеть иначе остальных.
const Color nameCol = (op == CraftOp::FRACTURE)
? COL_BLOOD
: (afford ? COL_TEXT : COL_FAINT);
Text(l, CraftOpName(op), r.x + 6, r.y + 3, 9, nameCol);
Text(l, CraftOpEffect(op), r.x + 6, r.y + 12, 7, COL_FAINT);
// Цена справа: только те материалы, которые нужны.
char price[64] = {0};
int used = 0;
for (int m = 0; m < MATERIAL_COUNT; ++m)
{
if (cost.amount[m] <= 0) continue;
char part[24];
std::snprintf(part, sizeof(part), "%s%d %s", used ? " " : "", cost.amount[m],
MaterialToken(MaterialKind(m)));
std::snprintf(price + used, sizeof(price) - size_t(used), "%s", part);
used = int(std::strlen(price));
}
if (used > 0)
Text(l, price, r.x + r.w - 6 - TextW(l, price, 7), r.y + 5, 7,
afford ? COL_PICK : COL_DEAD);
}
// --- чем кончилась последняя операция ---
// Молчать нельзя ни в одном из четырёх случаев: «рассыпалось» — это
// потеря вещи, «не хватило» — несостоявшийся клик, а успех без
// ответа выглядит как неработающая кнопка.
if (craftMessage > 0)
{
const CraftResult r = CraftResult(craftMessage - 1);
const char* what = "";
Color col = COL_TEXT;
switch (r)
{
case CraftResult::OK:
what = "DONE";
col = Color{150, 220, 170, 255};
break;
case CraftResult::NO_MATERIALS:
what = "NOT ENOUGH MATERIALS";
col = COL_FAINT;
break;
case CraftResult::NOT_APPLICABLE:
what = "NOTHING TO CHANGE";
col = COL_FAINT;
break;
default:
what = "THE ITEM CRUMBLED TO DUST";
col = COL_BLOOD;
break;
}
std::snprintf(line, sizeof(line), "%s: %s",
CraftOpName(CraftOp(craftOpDone)), what);
TextCentered(l, line, 254, 8, col);
}
TextCentered(l, "CLICK ITEM THEN OPERATION Q/E SCROLL ESC BACK", 264, 7,
COL_FAINT);
break;
}
case Screen::DEPTH:
{
Shade(l, COL_DIM);
TextCentered(l, "DESCEND FROM", 46, 16, COL_TEXT);
Rule(l, 70, 62);
char line[80];
const int best = game.profile ? game.profile->maxDepth : 1;
std::snprintf(line, sizeof(line), "DEEPEST REACHED D%d", best);
TextCentered(l, line, 76, 8, COL_FAINT);
for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], i == depthIndex);
// Главное правило экстракшена, и его надо повторять: глубже —
// богаче, но потерянное не возвращается.
TextCentered(l, "DEEPER MEANS RICHER AND FINAL", 226, 8, COL_FAINT);
TextCentered(l, "MOUSE OR W/S SELECT ESC BACK", 240, 8, COL_FAINT);
break;
}
case Screen::SUMMARY:
{
const RunReport& r = game.report;
const bool won = r.outcome == RunOutcome::EXTRACTED;
Shade(l, won ? Color{4, 10, 8, 232} : Color{12, 2, 2, 232});
TextCentered(l, won ? "EXTRACTED" : "SQUAD LOST", 60, 22,
won ? COL_TEXT : COL_BLOOD);
Rule(l, 90, 70);
char line[80];
std::snprintf(line, sizeof(line), "REACHED D%d", r.deepest);
TextCentered(l, line, 104, 12, COL_PICK);
// Из скольких ушли, а не из скольких бывает: «2 / 5» у игрока с
// отрядом из двух читалось бы как потеря троих.
std::snprintf(line, sizeof(line), "SURVIVORS %d / %d", r.survivors,
std::max(r.survivors, game.squad.roster));
TextCentered(l, line, 126, 9, COL_FAINT);
// Две цифры, ради которых экран и существует: что осталось у тебя
// и что осталось внизу.
std::snprintf(line, sizeof(line), "BROUGHT BACK %d", r.itemsBrought);
TextCentered(l, line, 140, 9, won ? COL_TEXT : COL_DEAD);
std::snprintf(line, sizeof(line), "LOST %d", r.itemsLost);
TextCentered(l, line, 152, 9, COL_BLOOD);
std::snprintf(line, sizeof(line), "XP %d", int(r.xp));
TextCentered(l, line, 166, 9, COL_FAINT);
if (r.newRecord)
{
std::snprintf(line, sizeof(line), "NEW CHECKPOINT D%d", r.checkpoint);
TextCentered(l, line, 184, 10, pulse ? COL_PICK : COL_TEXT);
}
for (int i = 0; i < w.count; ++i) DrawButton(l, *this, w.item[i], pulse);
break;
}
case Screen::SQUAD:
DrawSquadScreen(l, game, *this, w);
break;
}
// Курсор и то, что на нём висит, — поверх всего экрана.
if (!mouseIn) return;
// Подсказка — до курсора, чтобы курсор остался сверху, и НЕ во время
// перетаскивания: там под курсором уже висит вещь, и две подписи разом
// читаются хуже, чем одна.
if (screen == Screen::SQUAD && !dragging && hoverPart != UiPart::NONE)
{
const Widget* hov = w.Find(hoverPart, hoverIndex);
if (hov)
{
if (hoverPart == UiPart::STAT)
DrawTip(l, StatTip(hoverIndex, game.squad.agents[agentIndex]), int(mx), int(my));
else if (hov->item != ItemId::NONE)
{
Tip t = ItemTip(hov->item);
// Сравниваем только то, что ещё НЕ надето: наводя на слот
// бойца, игрок и так смотрит на надетое.
if (hoverPart == UiPart::STASH || hoverPart == UiPart::BAG)
if (const Inventory* inv =
game.world.Get<Inventory>(game.squad.agents[agentIndex].self))
AddCompare(t, hov->item, *inv);
DrawTip(l, t, int(mx), int(my));
}
}
}
DrawDragged(l, *this);
DrawPointer(l, *this);
}