#include "render/hud.h" #include #include "engine/microfont.h" #include "game.h" #include "render/sprites.h" namespace { constexpr int CARD_W = 92; constexpr int CARD_H = 22; constexpr int CARD_GAP = 2; constexpr int BAR_W = 52; const uint32_t COL_FRAME = RGBA(24, 26, 32); const uint32_t COL_EMPTY = RGBA(44, 48, 56); const uint32_t COL_DEAD = RGBA(70, 26, 26); // Рамка карточки: тёмная подложка, чтобы HUD читался поверх любого пола. void Panel(Framebuffer& fb, int x, int y, int w, int h, uint32_t fill) { fb.FillRect(x, y, w, h, fill); fb.Line(x, y, x + w - 1, y, COL_FRAME); fb.Line(x, y + h - 1, x + w - 1, y + h - 1, COL_FRAME); fb.Line(x, y, x, y + h - 1, COL_FRAME); fb.Line(x + w - 1, y, x + w - 1, y + h - 1, COL_FRAME); } // Патроны: по квадратику на выстрел, пока их не больше BAR_W/2. Дальше — // обычная полоса: сорок квадратиков автомата разглядывать всё равно никто // не будет, а «сколько осталось» видно и так. // Запас патронов СВЕРХ магазина — тонкая полоса под патронами. // // Это главный ресурс вылазки, и он обязан таять на глазах: игрок решает «ещё // этаж или наверх» именно по нему. Пустой запас — красная риска на месте // полосы: не «полоса нулевой длины», которую не отличить от её отсутствия. void DrawReserve(Framebuffer& fb, int x, int y, const Agent& a) { if (a.ammoReserve < 0) return; // песочница: патроны бесконечны const float k = Clampf(float(a.ammoReserve) / std::max(1.0f, g_tune.startAmmo), 0.0f, 1.0f); fb.FillRect(x, y, BAR_W, 1, COL_EMPTY); if (a.ammoReserve > 0) fb.FillRect(x, y, std::max(1, int(float(BAR_W) * k)), 1, (k < 0.2f) ? RGBA(220, 120, 60) : RGBA(150, 140, 110)); else fb.FillRect(x + BAR_W / 2 - 2, y, 4, 1, RGBA(220, 70, 60)); } void DrawAmmo(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col) { const int mag = a.gun.def.magazine; if (a.gun.Reloading()) { // Перезарядка: полоса набирается слева направо. Это ЕДИНСТВЕННЫЙ момент, // когда стрелок беспомощен, и его надо видеть без чтения цифр. const float k = 1.0f - Clampf(a.gun.reloadTimer / std::max(0.01f, a.gun.def.reloadTime), 0.0f, 1.0f); fb.FillRect(x, y, BAR_W, 3, COL_EMPTY); fb.FillRect(x, y, int(float(BAR_W) * k), 3, RGBA(210, 150, 60)); return; } if (mag > 0 && mag <= BAR_W / 2) { const int step = BAR_W / mag; for (int i = 0; i < mag; ++i) fb.FillRect(x + i * step, y, std::max(1, step - 1), 3, (i < a.gun.ammo) ? col : COL_EMPTY); return; } fb.FillRect(x, y, BAR_W, 3, COL_EMPTY); fb.FillRect(x, y, int(float(BAR_W) * a.gun.AmmoFrac()), 3, col); } // Клинок: фаза удара. Замах, удар и восстановление — три разных цвета, потому // что это три разных состояния бойца, а не одна «атака». void DrawMeleeState(Framebuffer& fb, int x, int y, const Agent& a, uint32_t col) { uint32_t c = COL_EMPTY; float k = 1.0f; switch (a.melee.state) { case MeleeState::WINDUP: c = RGBA(220, 180, 70); k = a.melee.Progress(); break; case MeleeState::STRIKE: c = RGBA(250, 240, 220); k = 1.0f; break; case MeleeState::RECOVER: c = RGBA(150, 70, 70); k = 1.0f - a.melee.Progress(); break; default: // Готов бить — полоса цвета бойца; иначе видно, сколько ждать. c = (a.melee.cooldown <= 0.0f) ? col : COL_EMPTY; k = (a.melee.cooldown <= 0.0f) ? 1.0f : 1.0f - Clampf(a.melee.cooldown / std::max(0.01f, a.melee.interval), 0.0f, 1.0f); break; } fb.FillRect(x, y, BAR_W, 3, COL_EMPTY); fb.FillRect(x, y, int(float(BAR_W) * Clampf(k, 0.0f, 1.0f)), 3, c); } // ----------------------------------------------------------------------------- // Строка забега: этаж, отделка, опыт. Единственные БУКВЫ в HUD, и появились // они по одной причине: номер этажа — это число, и показать его формой нельзя // (engine/microfont.h). Всё остальное в HUD по-прежнему без единой буквы. // ----------------------------------------------------------------------------- void DrawRunBar(Framebuffer& fb, const Game& game) { if (!game.levelGenerated) return; // на захардкоженной карте этажа нет const uint32_t COL_TEXT = RGBA(206, 200, 190); const uint32_t COL_SHADOW = RGBA(6, 6, 8); const uint32_t COL_READY = RGBA(120, 226, 150); // вниз, глубже const uint32_t COL_OUT = RGBA(130, 200, 240); // наверх, с добычей char buf[48]; FloorLabel(game.level.floor, buf, sizeof(buf)); MicroTextShadow(fb, 5, 5, buf, COL_TEXT, COL_SHADOW); // Добыча — вторая по важности цифра забега после этажа: именно она стоит // на кону, и знать её нужно ДО того, как решаешь идти глубже. // Сумка одна на отряд, поэтому и цифра одна: пересчитывать по бойцам // больше нечего. const int carried = game.squad.bag.Count(); std::snprintf(buf, sizeof(buf), "XP %d LOOT %d", int(game.xp), carried); MicroTextShadow(fb, 5, 5 + MICRO_H + 3, buf, COL_TEXT, COL_SHADOW); // Свет — второй ресурс вылазки, и таять он обязан на глазах так же, как // патроны в карточке бойца. Разница одна: кончившийся заряд показывается // СЛОВОМ, а не полосой нулевой длины. Темнота меняет правила — и обзор, и // то, кто кого замечает первым, — а «полоски не видно» читалось бы как // «полоски тут и не было». if (game.squad.lightCharge >= 0.0f) { const int ly = 5 + (MICRO_H + 3) * 2; if (game.squad.LightOut()) MicroTextShadow(fb, 5, ly, "DARK", RGBA(225, 95, 70), COL_SHADOW); else { const float k = Clampf(game.squad.lightCharge / LIGHT_FULL, 0.0f, 1.0f); fb.FillRect(5, ly + 1, 46, 3, RGBA(44, 48, 56)); fb.FillRect(5, ly + 1, std::max(1, int(46.0f * k)), 3, (k < 0.25f) ? RGBA(220, 150, 60) : RGBA(232, 214, 150)); } } // Подсказки выходов. Только когда отряд УЖЕ на них: постоянная строка // «нажми F» висела бы на экране весь забег и перестала бы читаться. // // Разные цвета — те же, что у колец на полу: подсказка обязана читаться // как подпись к тому, на чём стоишь, а не как отдельное сообщение. // Уйти можно откуда угодно, поэтому «EXTRACT: E» висит всегда — но тускло и // мелко, как справка. Спуск наоборот: он привязан к месту, и его подсказка // появляется только на кольце, зато в полную силу. if (game.atExit) MicroTextShadow(fb, (fb.w - MicroTextWidth("DESCEND: F")) / 2, 14, "DESCEND: F", COL_READY, COL_SHADOW); MicroTextShadow(fb, fb.w - MicroTextWidth("EXTRACT: E") - 5, 5, "EXTRACT: E", COL_OUT, COL_SHADOW); } } // namespace void DrawHud(Framebuffer& fb, const Game& game) { DrawRunBar(fb, game); // Карточки только на ЗАНЯТЫЕ слоты, и ряд считается по ним же. Отряд растёт // с одного до пяти, и четыре пустые рамки рядом с одиноким оперативником // читались бы как «тут кто-то был» — а там никого и не было. int manned = 0; for (int i = 0; i < NUM_AGENTS; ++i) if (game.squad.agents[i].present) ++manned; if (manned <= 0) return; const int total = manned * CARD_W + (manned - 1) * CARD_GAP; const int x0 = (fb.w - total) / 2; const int y0 = fb.h - CARD_H - 4; int card = 0; for (int i = 0; i < NUM_AGENTS; ++i) { const Agent& a = game.squad.agents[i]; if (!a.present) continue; const int x = x0 + (card++) * (CARD_W + CARD_GAP); const uint32_t col = SlotColor(i); // Подложка вспыхивает, когда бойца ударили: цифры смотреть некогда, // а красная карточка в углу глаза видна. uint32_t back = RGBA(12, 13, 16); if (!a.alive) back = COL_DEAD; else if (a.hurtTimer > 0.0f) back = ShadeColor(RGBA(120, 30, 30), 0.4f + a.hurtTimer); Panel(fb, x, y0, CARD_W, CARD_H, back); // Метка слота: тот же цвет, что и у тела на карте. fb.FillRect(x + 3, y0 + 3, 4, CARD_H - 6, a.alive ? col : ShadeColor(col, 0.35f)); if (!a.alive) { // Перечёркнутая карточка: потерю отряда нельзя пропустить. fb.Line(x + 10, y0 + 4, x + CARD_W - 6, y0 + CARD_H - 5, RGBA(180, 60, 60)); fb.Line(x + 10, y0 + CARD_H - 5, x + CARD_W - 6, y0 + 4, RGBA(180, 60, 60)); continue; } // Здоровье. const float hpK = Clampf(a.hp / std::max(1.0f, a.maxHp), 0.0f, 1.0f); const uint32_t hpCol = (hpK > 0.5f) ? RGBA(150, 200, 130) : (hpK > 0.25f) ? RGBA(220, 190, 90) : RGBA(220, 80, 70); fb.FillRect(x + 10, y0 + 4, BAR_W, 5, COL_EMPTY); fb.FillRect(x + 10, y0 + 4, int(float(BAR_W) * hpK), 5, hpCol); // Броня: тонкая полоска под здоровьем. Её нет — нет и полоски. if (a.armor > 0.0f) fb.FillRect(x + 10, y0 + 10, int(float(BAR_W) * Clampf(a.armor / ARMOR_CAP, 0.0f, 1.0f)), 2, RGBA(120, 150, 190)); // Нагрузка. Полоска появляется ТОЛЬКО за нормой переноски: пока боец // идёт как шёл, показывать нечего, а лишняя линия в каждой карточке // отняла бы внимание у здоровья и патронов. // // Она растёт от нормы к пределу и там же кончается — это и есть шкала // «насколько ты уже обоз». if (a.load > CARRY_LIMIT) { const float over = Clampf((a.load - CARRY_LIMIT) / (CARRY_MAX - CARRY_LIMIT), 0.0f, 1.0f); fb.FillRect(x + 10, y0 + 12, BAR_W, 2, COL_EMPTY); fb.FillRect(x + 10, y0 + 12, int(float(BAR_W) * over), 2, (over > 0.85f) ? RGBA(220, 90, 60) : RGBA(200, 160, 70)); } // Оружие: патроны или фаза клинка. if (a.IsMelee()) DrawMeleeState(fb, x + 10, y0 + 14, a, col); else { DrawAmmo(fb, x + 10, y0 + 14, a, col); DrawReserve(fb, x + 10, y0 + 19, a); } // Иконка того, что в руках. fb.Blit(game.sprites.Icon(a.held), x + CARD_W - 12, y0 + CARD_H / 2); // Есть цель — уголок над карточкой. Отряд, который никого не видит, // должен отличаться от отряда, который работает. const bool engaged = a.IsMelee() ? a.melee.inReach : a.hasLane; if (engaged) fb.FillRect(x + CARD_W / 2 - 3, y0 - 3, 6, 2, RGBA(230, 220, 200)); } }