#include "ui/menu.h" #include #include #include #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; // Экран улучшений: четыре строки во всю ширину. Строкой, а не сеткой, потому // что у каждой есть длинная подпись «что это даёт», и без неё покупка была бы // покупкой кота в мешке. // Строк на экране улучшений пять: найм и четыре характеристики. Найм ПЕРВЫЙ // строкой — он и стоит на порядок дороже, и меняет игру сильнее всех остальных // покупок вместе взятых (sim/profile.h). constexpr int UP_X = 60, UP_Y = 66, UP_W = 360, UP_H = 26, UP_STEP = 30; // Индекс строки найма в списке виджетов. Стоит за последним улучшением, чтобы // не переиндексировать Upgrade ради UI. constexpr int UP_RECRUIT = int(Upgrade::COUNT); // Строка i-го улучшения. Найм занимает нулевую, характеристики идут следом. inline UiRect UpRowRect(int row) { return UiRect{UP_X, UP_Y + row * UP_STEP, UP_W, UP_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; // Выбор чекпоинта: столбец кнопок «начать с этажа 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); if (d.charge > 0.0f) t.Add("RELIGHTS LANTERN: %d PACES", int(d.charge)); 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: out.Add(UiPart::UPGRADE_ITEM, UP_RECRUIT, UpRowRect(0)); for (int i = 0; i < int(Upgrade::COUNT); ++i) out.Add(UiPart::UPGRADE_ITEM, i, UpRowRect(i + 1)); 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(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(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(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) return; if (index == UP_RECRUIT) game.profile->Recruit(); else 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) { // Листается только по ЗАНЯТЫМ слотам: пустые пролистываются // насквозь, иначе половина нажатий показывала бы пустоту. 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(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); // --- найм: первая строка и единственная покупка, которая приводит // ЧЕЛОВЕКА, а не число. { const int size = game.profile ? game.profile->squadSize : NUM_AGENTS; const bool full = size >= NUM_AGENTS; const bool afford = game.profile && game.profile->CanRecruit(); const UiRect r = UpRowRect(0); const bool hover = (hoverPart == UiPart::UPGRADE_ITEM && hoverIndex == UP_RECRUIT); if (hover && afford) Box(l, r, COL_HOVER); BoxLines(l, r, afford ? COL_EDGE : COL_DEAD); Text(l, "RECRUIT OPERATIVE", r.x + 8, r.y + 4, 10, afford ? COL_TEXT : COL_FAINT); std::snprintf(line, sizeof(line), "SQUAD OF %d, ONE MORE GUN AND ONE MORE MOUTH", size); Text(l, line, r.x + 8, r.y + 16, 8, COL_FAINT); for (int k = 0; k < NUM_AGENTS; ++k) Box(l, UiRect{r.x + 200 + k * 8, r.y + 8, 5, 5}, (k < size) ? Color{200, 180, 120, 255} : COL_DEAD); if (full) std::snprintf(line, sizeof(line), "FULL"); else std::snprintf(line, sizeof(line), "%d XP", int(RecruitCost(size))); Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10, full ? COL_FAINT : (afford ? COL_PICK : COL_DEAD)); } 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 = UpRowRect(i + 1); 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); // Из скольких ушли, а не из скольких бывает: «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); }