squad-proto/src/ui/menu.cpp

1281 lines
56 KiB
C++
Raw Normal View History

Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
#include "ui/menu.h"
#include <raylib.h>
#include <cstdarg>
#include <cstdio>
#include "game.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;
// Экран улучшений: четыре строки во всю ширину. Строкой, а не сеткой, потому
// что у каждой есть длинная подпись «что это даёт», и без неё покупка была бы
// покупкой кота в мешке.
constexpr int UP_X = 60, UP_Y = 70, UP_W = 360, UP_H = 26, UP_STEP = 32;
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;
// Выбор чекпоинта: столбец кнопок «начать с этажа 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", "QUIT"};
constexpr int MENU_COUNT = 5;
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; // кегль подписи в пикселях кадра
ItemId item = ItemId::NONE; // что лежит в ячейке
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(ItemId id)
{
const ItemDef d = ItemDefOf(id);
Tip t;
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);
Свет как расходник: заряд тратится за путь, а не за время (M23) Второй ресурс вылазки после патронов. Фонарь садится, погас — обзор падает до 35%, а твари замечают отряд в 1.35 раза дальше: отряд не слепнет, он меняется с тварями местами. Батарея встаёт сама, как бинт. Главное решение — расход за ПРОЙДЕННЫЙ ПУТЬ, а не за время. Первая версия считала по секундам, то есть была таймером, которого в игре нет по прямому требованию к жанру: садящийся сам собой фонарь торопил бы игрока ровно как секундомер, только без циферблата, и думать над этажом стало бы дорого. За расстояние получается тот же размен, что и с весом, но с другой стороны: вес делает дорогим «унести всё», свет — «обшарить всё». Заряд общий на отряд и едет вниз вместе с ним: обновляйся он на этаже, это был бы ресурс этажа, а не вылазки, и никуда бы не гнал. Чем глубже, тем дороже шаг, а этажи внизу длиннее — глубина жмёт на свет дважды. Проверка в --descend меряет правило целиком, и первая её строка не про баланс, а про жанр: заряд на стоянке обязан стоять. Сам измеритель тоже был неправ — делил потраченное на смещение отряда, а не на пройденный путь, и показывал 1.55 за тайл при 1.10 в каталоге. свет: стоянка 0.2, ход 1.10 за тайл при 1.10 в каталоге глубина 8: заряда на 383 тайла, это 4.0 прямых пути до спуска обзор 1.00 -> 0.35 в темноте, батарея встала сама Хватает ли заряда на этаж считается в этажах и по средней длине забега: путь до спуска скачет вдвое от сида, и порог по одному этажу ловил бы удачу, а не баланс. --accept PASS без изменений: в песочнице света бесконечно, как и патронов. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 02:12:05 +07:00
if (d.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge));
Tile2D: движок отдельным продуктом, игра — проектом в нём (M11-M20) Десять вех одним состоянием: разделять их пофайлово нечем, потому что src/game.cpp менялся почти в каждой. Собирается начисто без предупреждений, все проверки проходят. ДВИЖОК (engine/, цель tile2d) Каталог свой, и решение «это движок» принимается тем, куда положен файл. Компилятор нашёл три зависимости движка от игры, и все сняты: размеры кадра и карты уехали в engine/config.h, потолок населения этажа стал своим (MAX_LEVEL_SPAWNS против игровой MAX_TARGETS), запасная точка старта стала центром карты вместо позиции отряда. engine/projection.* единственное место, где движок знает про изометрию; видов сразу два — абстракция с одной реализацией ничем не проверена и обычно оказывается неправильной engine/project.* манифест проекта: пути, вид, кривая глубины engine/app.* окно, кадр, fixed timestep — вместо двух копий цикла engine/tilemap_draw движок научился показывать свою карту РЕДАКТОР (editor/, цель tile2d_editor) Открывает любой проект и ни одной игры по имени не знает. --tool одиннадцать безоконных команд: комнаты, каталог, кривая, сборка этажа, ASCII-карта, проверка набора. Правку делают файлами, последствия читают командой (docs/19-agent.md) терминал снизу: те же инструменты, git, сборка и запросы к claude/codex, открытый проект уходит потомкам через TILE2D_PROJECT ПРИМЕР (samples/walk, цель tile2d_walk) Второй потребитель движка. Игра и редактор самостоятельность движка проверить не могут: редактор мира не рисует, а игра — то место, куда общий код и уезжает незаметно. ИГРА (src/) Экстракшен: вес и два порога переноски, конечный боезапас, расходники применяются сами, общая сумка отряда, трата опыта на улучшения. Каждое из правил проверяется прогоном, а не глазами, и проверки поймали: вес дважды не связывал ничего (мерил по бинтам прогона, а не по добыче); боец с клинком не тратил найденные патроны при запасном стволе в сумке; массив виджетов экрана переполнился и молча съел хвост схрона; проверка улучшений сама ломала забег, вызывая NewRun посреди спуска. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 01:07:19 +07:00
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};
}
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 < int(Upgrade::COUNT); ++i)
out.Add(UiPart::UPGRADE_ITEM, i, UiRect{UP_X, UP_Y + i * UP_STEP, UP_W, UP_H});
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:
{
for (int i = 0; i < NUM_AGENTS; ++i)
{
Widget& w = out.Add(UiPart::AGENT, i,
UiRect{SQ_CARD_X + i * 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)]
: ItemId::NONE;
}
}
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);
}
// Надетое живёт в инвентаре бойца, сумка — в отряде, поэтому указатель на
// ячейку берётся из двух разных мест. Вызывающему это знать незачем: для него
// всё это по-прежнему «слот, в котором лежит вещь».
ItemId* 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, а в профиле, и ячейка за концом списка — это
// место, куда предмет КЛАДЁТСЯ. Поэтому обмен со схроном разобран отдельно:
// указателя на несуществующую ячейку не бывает.
ItemId StashTake(Profile& pr, int index)
{
if (index < 0 || index >= pr.StashCount()) return ItemId::NONE;
const ItemId out = pr.stash[size_t(index)];
pr.StashRemoveAt(index);
return out;
}
// false — схрон полон и предмет остался на месте. Молча терять его нельзя.
bool StashPut(Profile& pr, int index, ItemId id)
{
if (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;
ItemId* slot = SlotPtr(*inv, game.squad.bag, part, index);
if (!slot || *slot == ItemId::NONE) return;
// Задержка подбора обязательна: вещь ложится там же, где стоит боец, и
// без неё тот же шаг поднял бы её обратно.
SpawnFloorItem(game.world, *slot, a.pos, 2.0f);
*slot = ItemId::NONE;
}
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 ItemId moved = StashTake(pr, ui.dragIndex);
StashPut(pr, toIndex, moved);
return;
}
ItemId* slot = SlotPtr(*inv, game.squad.bag, fromStash ? to : ui.dragFrom,
fromStash ? toIndex : ui.dragIndex);
if (!slot) return;
if (fromStash)
{
// Из схрона в слот: то, что лежало в слоте, уезжает в схрон. Если
// схрон переполнен, обмен не состоится целиком — иначе вещь из
// слота исчезла бы.
const ItemId taken = pr.stash.empty() ? ItemId::NONE : StashTake(pr, ui.dragIndex);
if (taken == ItemId::NONE) return;
const ItemId back = *slot;
if (!StashPut(pr, ui.dragIndex, back)) { StashPut(pr, ui.dragIndex, taken); return; }
*slot = taken;
}
else
{
const ItemId moved = *slot;
if (moved == ItemId::NONE) return;
if (!StashPut(pr, toIndex, moved)) return; // схрон полон
*slot = ItemId::NONE;
}
// Руки могли опустеть или наполниться — сущность-оружие обязана
// поменяться вместе с ними.
if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS)
EquipWeapon(game.world, a, inv->weapon);
return;
}
ItemId* src = SlotPtr(*inv, game.squad.bag, ui.dragFrom, ui.dragIndex);
ItemId* dst = SlotPtr(*inv, game.squad.bag, to, toIndex);
if (!src || !dst || src == dst) return;
const ItemId moved = *src;
*src = *dst;
*dst = moved;
// Руки поменялись — обязана поменяться и сущность-оружие. Иначе имя новое,
// а статы, класс и правила стрельбы остались от прежнего ствола.
if (ui.dragFrom == UiPart::HANDS || to == UiPart::HANDS)
EquipWeapon(game.world, a, inv->weapon);
}
// -----------------------------------------------------------------------------
// Действия кнопок. Один вход и для клика, и для ENTER: иначе мышь и клавиатура
// начнут делать «почти одно и то же».
// -----------------------------------------------------------------------------
void Activate(Ui& ui, Game& game, UiPart part, int index)
{
switch (part)
{
case UiPart::UPGRADE_ITEM:
// Покупка молча не проваливается: не хватило опыта — уровень
// остался прежним, и это видно на той же строке.
if (game.profile) game.profile->Buy(Upgrade(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;
default: ui.quit = true; 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::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;
}
}
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
// -----------------------------------------------------------------------------
// Ввод
// -----------------------------------------------------------------------------
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)
Activate(*this, game, hot->part, hot->index);
else if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_KP_ENTER))
Activate(*this, game, UiPart::MENU_ITEM, menuIndex);
return true;
}
case Screen::PAUSE:
if (lmbDown && hot) { Activate(*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) { Activate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE))
Activate(*this, game, UiPart::DEFEAT_ITEM, 0);
if (Pressed(KEY_ESCAPE)) screen = Screen::MENU;
return true;
case Screen::SUMMARY:
if (lmbDown && hot) { Activate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE) || Pressed(KEY_ESCAPE))
Activate(*this, game, UiPart::SUMMARY_ITEM, 0);
return true;
case Screen::UPGRADE:
if (lmbDown && hot) Activate(*this, game, hot->part, hot->index);
if (Pressed(KEY_ESCAPE) || Pressed(KEY_U)) 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) { Activate(*this, game, hot->part, hot->index); return true; }
if (Pressed(KEY_ENTER) || Pressed(KEY_SPACE))
{
const Widget& pick = w.item[depthIndex];
Activate(*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)
{
if (Pressed(KEY_RIGHT) || Pressed(KEY_D))
agentIndex = (agentIndex + 1) % NUM_AGENTS;
if (Pressed(KEY_LEFT) || Pressed(KEY_A))
agentIndex = (agentIndex + NUM_AGENTS - 1) % NUM_AGENTS;
}
// ENTER — взять из сумки в руки. Обратно уезжает то, что было:
// отряд не носит воздух, у него всегда что-то в руках.
if (Pressed(KEY_ENTER) || Pressed(KEY_KP_ENTER))
{
// Взять из ОБЩЕЙ сумки в руки выбранному бойцу, а бывшее в
// руках положить на его место. Разрешено только оружие:
// бинтом отбиваться нечем.
Agent& a = game.squad.agents[agentIndex];
Inventory* inv = game.world.Get<Inventory>(a.self);
ItemId& 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, ItemId id, int x, int y, int h, bool bright)
{
if (id == ItemId::NONE)
{
Text(l, "-", x + 2, y + h / 2 - 4, 8, COL_FAINT);
return;
}
const ItemDef d = ItemDefOf(id);
Box(l, UiRect{x, y + h / 2 - 4, 8, 8}, Color{d.r, d.g, d.b, 255});
Text(l, d.name, x + 12, y + h / 2 - 4, 8, bright ? COL_TEXT : COL_FAINT);
}
// Ячейка инвентаря. Рисуется строго по прямоугольнику из таблицы — тому же,
// по которому мышь проверяет попадание.
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 ? ItemId::NONE : 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, "UPGRADES", 30, 16, COL_TEXT);
Rule(l, 52, 52);
char line[80];
const float banked = game.profile ? game.profile->bankedXp : 0.0f;
std::snprintf(line, sizeof(line), "BANKED XP %d", int(banked));
TextCentered(l, line, 58, 8, COL_FAINT);
for (int i = 0; i < int(Upgrade::COUNT); ++i)
{
const Upgrade u = Upgrade(i);
const int lvl = game.profile ? game.profile->UpgradeLevel(u) : 0;
const bool maxed = lvl >= UPGRADE_MAX_LEVEL;
const bool afford = game.profile && game.profile->CanBuy(u);
const UiRect r{UP_X, UP_Y + i * UP_STEP, UP_W, UP_H};
const bool hover = (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex == i);
if (hover && afford) Box(l, r, COL_HOVER);
BoxLines(l, r, afford ? COL_EDGE : COL_DEAD);
Text(l, UpgradeName(u), r.x + 8, r.y + 4, 10, afford ? COL_TEXT : COL_FAINT);
Text(l, UpgradeEffect(u), r.x + 8, r.y + 16, 8, COL_FAINT);
// Уровни точками: пять кружков читаются быстрее, чем «3/5».
for (int k = 0; k < UPGRADE_MAX_LEVEL; ++k)
Box(l, UiRect{r.x + 200 + k * 8, r.y + 8, 5, 5},
(k < lvl) ? Color{200, 180, 120, 255} : COL_DEAD);
if (maxed) std::snprintf(line, sizeof(line), "MAX");
else std::snprintf(line, sizeof(line), "%d XP", int(UpgradeCost(lvl)));
Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10,
maxed ? COL_FAINT : (afford ? COL_PICK : COL_DEAD));
}
TextCentered(l, "CLICK TO BUY ESC BACK", 232, 8, 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);
std::snprintf(line, sizeof(line), "SURVIVORS %d / %d", r.survivors, NUM_AGENTS);
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);
}