Перетаскивание мышью в кадре 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>
2023 lines
96 KiB
C++
2023 lines
96 KiB
C++
#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);
|
||
}
|