#include "ui/menu.h" #include #include #include #include #include "game.h" #include "sim/affix.h" #include "sim/craft.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; // Мастерская. Слева схрон той же сеткой, что и на экране отряда (привычка // игрока дороже экономии места), справа пять операций строками с ценой. 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; constexpr int WS_OP_X = 252, WS_OP_Y = 96, WS_OP_W = 212, WS_OP_H = 22, WS_OP_STEP = 25; // Выбор чекпоинта: столбец кнопок «начать с этажа 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: out.Add(UiPart::UPGRADE_ITEM, UP_RECRUIT, UpRowRect(0)).enabled = false; 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)] : 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)); } 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(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(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: // Покупка молча не проваливается: не хватило опыта — уровень // остался прежним, и это видно на той же строке. if (!game.profile) return; // Нулевая строка — состав отряда, и она НЕ кнопка: оперативники // находятся на этажах (sim/profile.h). if (index != UP_RECRUIT) 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; 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: // Выбор вещи на верстак. Ничего не тратит и ничего не ломает: // операция — это отдельный клик. ui.craftIndex = 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& it = pr.stash[size_t(ui.craftIndex)]; const CraftResult r = ApplyCraft(CraftOp(index), it, pr.material, pr.maxDepth, rng); // Рассыпавшаяся вещь убирается из схрона: пустая ячейка посреди // списка выглядела бы как ошибка, а не как последствие риска. 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(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, "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); // --- состав отряда: показ, а не покупка ------------------------- // Раньше здесь стояла строка «нанять оперативника» за опыт. Убрана // намеренно: наёмники теперь НАХОДЯТСЯ в капсулах на этажах // (sim/profile.h), а кнопка «стать сильнее» никуда не звала и // ничего не рассказывала. { const int size = game.profile ? game.profile->squadSize : NUM_AGENTS; const UiRect r = UpRowRect(0); BoxLines(l, r, COL_DEAD); Text(l, "SQUAD", r.x + 8, r.y + 4, 10, COL_FAINT); Text(l, "OPERATIVES ARE FOUND IN CAPSULES, NOT HIRED", 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); std::snprintf(line, sizeof(line), "%d / %d", size, NUM_AGENTS); Text(l, line, r.x + r.w - 8 - TextW(l, line, 10), r.y + 7, 10, 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 = 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::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", 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); } // --- что лежит на верстаке --- 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))); Text(l, af, WS_SLOT_X, 200 + a * 9, 8, 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 + 13, 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, 244, 9, col); } TextCentered(l, "CLICK ITEM THEN OPERATION Q/E SCROLL ESC BACK", 258, 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); }