squad-proto/src/ui/menu.cpp
z.kirill 92a8864726 Крафт стал выбором: закалка, отпечаток, направленные операции (M33)
Прежние пять операций были СЛЕПЫМИ: перековка бросала всё заново,
прививка ставила случайный тег, отсечение снимало последний аффикс,
возвышение поднимало случайный. Игрок не принимал ни одного решения - жал
кнопку и смотрел, что выпало. Это не крафт, это второй бросок предмета за
материалы.

Теперь половина операций направленные: игрок показывает пальцем, какой
аффикс трогать, и платит за это вдвое - вот это уже решение
("перебросить всё дёшево или снять один дорого").

Два новых глагола:
  ЗАКАЛКА двигает рулон выбранного аффикса к максимуму его тира на треть
  остатка. Дёшево, понятно, повторяемо - то самое "доработать почти
  хорошую вещь". Тир не трогает: за ступень платят возвышением.

  ОТПЕЧАТОК переносит лучший аффикс с ДРУГОЙ вещи, сжигая её. Именно он
  превращает хлам из схрона в ресурс, а не в мусор, который лежит.
  Правило "один тег один раз" им не обходится, бюджет проверяется.

В мастерской: клик по вещи кладёт её на верстак, повторный клик по той же
назначает донором (помечается кровью - он сгорит, и видеть это надо ДО
нажатия). Строки аффиксов стали кнопками: по ним выбирается цель.

Проверка меряет НАПРАВЛЕННОСТЬ, а не факт срабатывания:

  направленность: отсечение по цели, возвышение по цели,
                  закалка двигает рулон, отпечаток перенёс и сжёг донора

Отсечение пробуется на СРЕДНЕМ аффиксе из трёх: операция, которая
"работает", снимая последний, прошла бы и слепую версию.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 19:37:07 +03:00

1706 lines
78 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;
// Схрон: то, что лежит на базе между вылазками. Двумя рядами во всю ширину
// внизу, а не колонкой сбоку: в схроне десятки предметов, и подпись каждого
// должна читаться — иначе выбор снаряжения превращается в перебор иконок.
constexpr int SQ_STASH_X = 22, SQ_STASH_Y = 208, SQ_STASH_W = 52, SQ_STASH_H = 18;
constexpr int SQ_STASH_GX = 4, SQ_STASH_GY = 3, SQ_STASH_COLS = 8, SQ_STASH_ROWS = 2;
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;
// Выбор чекпоинта: столбец кнопок «начать с этажа 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;
}
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));
UiRect box{mx + 8, my + 8, 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:
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];
}
// Строки характеристик тоже наводимы. Читать «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)
{
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;
}
bool SlotAccepts(UiPart part, ItemId id)
{
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)
{
return SlotAccepts(to, moved) && SlotAccepts(from, there);
}
// Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на
// ячейку берётся из двух разных мест. Вызывающему это знать незачем: для него
// всё это по-прежнему «слот, в котором лежит вещь».
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{};
}
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);
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);
}
} // 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::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
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 (Pressed(KEY_ESCAPE)) { screen = Screen::PAUSE; 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)
{
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);
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);
}
}
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);
}
}
Text(l, "BAG", SQ_BAG_X, SQ_BAG_Y - 14, 10, COL_TEXT);
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 (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, "DRAG ITEMS WITH MOUSE RMB ON ITEM DROPS IT", 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
{
if (screen == Screen::GAME) return;
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:
return;
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)
DrawTip(l, ItemTip(hov->item), int(mx), int(my));
}
}
DrawDragged(l, *this);
DrawPointer(l, *this);
}