#include "ui.h" #include "game.h" #include #include constexpr float BANNER_SCALE = 4; constexpr float BANNER_Y = 110; constexpr float BAR_GAP = 2; constexpr float LOADOUT_FADE = 0.25f; constexpr float LOADOUT_GAP = 4; constexpr float LOADOUT_DIVIDER = 2; constexpr float ICON_SIZE = 24; // How big a fashion item's cell is (its art is centerd in it). constexpr float FASHION_CELL = 32; constexpr float RIVAL_BAR_MIN = 16; constexpr float RIVAL_DIVIDER = 1; constexpr float RIVAL_GAP = 4; constexpr float RIVAL_ICON_GAP = 2; constexpr float RIVAL_ALPHA = 0.5f; constexpr float RIVAL_OUT_SHARE = 0.5f; constexpr float SPECTATE_GAP = 4; constexpr Control SPECTATE_CONTROLS[] = {CONTROL_LEFT, CONTROL_RIGHT, CONTROL_ATTACK}; constexpr float HUD_IDLE_ALPHA = 0.5f; constexpr float HUD_HOVER_ALPHA = 0.5f; constexpr float HUD_HOVER_FADE = 0.15f; constexpr float RIVAL_PADDING = 2; constexpr int RIVAL_COLUMNS_MAX = 3; // What the other fighters' windows try, from largest to smallest, until they all fit in a couple of rows: shrinking // icons before the font, then leaving the icons out. constexpr int RIVAL_ROWS_MAX = 2; // The most rows of full-size icons the windows can stack up between them before the icons shrink. constexpr int RIVAL_FULL_ICON_ROWS_MAX = 2; constexpr int RIVAL_STYLES = 6; constexpr int RIVAL_ICON_STYLES = 4; constexpr Font RIVAL_FONTS[RIVAL_STYLES] = {FONT_SMALL, FONT_SMALL, FONT_TINY, FONT_TINY, FONT_SMALL, FONT_TINY}; constexpr IconSize RIVAL_SIZES[RIVAL_ICON_STYLES] = {ICON_FULL, ICON_SMALL, ICON_SMALL, ICON_TINY}; constexpr float COOLDOWN_SLOTS = 2; constexpr float MINI_ICON_SCALE = 0.5f; constexpr float MINI_SLOT = 16; constexpr float MINI_STEP = 18; constexpr float PICK_PADDING = 4; constexpr float PLAYER_PANEL_HEIGHT = 56; constexpr float READY_BUTTON_HEIGHT = 18; constexpr float READY_BUTTON_WIDTH = 80; constexpr float READY_GAP = 2; constexpr float TIP_GAP = 4; constexpr float COUNTDOWN_Y = 40; constexpr float COUNTDOWN_DIM = 0.6f; constexpr const char* ICON_SUFFIXES[ICON_SIZE_COUNT] = {"", " Small", " Tiny"}; constexpr const char* CHEVRONS[ICON_SIZE_COUNT] = {"Chevron", "Chevron Small", "Chevron Tiny"}; constexpr float CHEVRON_INSETS[ICON_SIZE_COUNT] = {3, 2, 2}; constexpr float CHEVRON_STEPS[ICON_SIZE_COUNT] = {3, 2, 2}; constexpr float CHARGE_BAR_HEIGHT = 8; constexpr float CHARGE_BAR_WIDTH = 20; constexpr float DPS_SCALE = 2; constexpr float FIGHT_BANNER_TIME = 0.6f; // How many of the countdown's last seconds beep (and, in a timed round, show big). constexpr int COUNTDOWN_BEEPS = 5; constexpr float HEALTH_BAR_HEIGHT = 7; constexpr float HEALTH_BAR_WIDTH = 28; constexpr float ROUND_SCALE = 3; constexpr float ASIDE_LABEL_SCALE = 2; constexpr float ASIDE_PADDING = 4; constexpr float SCORE_SCALE = 2; constexpr float SCORE_HOTBAR_GAP = 8; constexpr float STATUS_BAR_HEIGHT = 12; constexpr float STATUS_BAR_WIDTH = 80; constexpr float STATUS_GAP = 6; // How thick a slot's (and its cooldown bar's) edge is at each size. constexpr float SLOT_STROKES[ICON_SIZE_COUNT] = {PANEL_BORDER, PANEL_BORDER, 1}; constexpr float HOVER_BORDER = 1; constexpr float HOVER_GAP = 1; constexpr float TARGET_GAP = 1; constexpr float TARGET_HEIGHT = 2; constexpr float TARGET_INSET = 4; constexpr float CARET_BLINK = 0.5f; constexpr float COOLDOWN_BAR_HEIGHTS[ICON_SIZE_COUNT] = {7, 6, 6}; constexpr float COOLDOWN_FADE_IN = 0.1f; constexpr float COOLDOWN_FADE_OUT = 0.25f; constexpr float COOLDOWN_GAP = 4; constexpr float COOLDOWN_ROW_GAP = 2; constexpr float COOLDOWN_SLIDE = 12; constexpr float OFFSCREEN_ARROW_SCALE = 2; constexpr float OFFSCREEN_MARGIN = 8; constexpr float OFFSCREEN_NAME_GAP = 10; constexpr float CARET_HEIGHT = 9; constexpr float CHECK_INSET = 4; constexpr float DISABLED_ALPHA = 0.4f; constexpr float RADIO_INSET = 4; constexpr float SCROLL_STEP = 24; constexpr float SWATCH_INSET = 1; constexpr float SWATCH_INSET_ON = 3; constexpr float FOCUS_ALIGN_WEIGHT = 0.01f; constexpr float FOCUS_ARM = 4; constexpr float FOCUS_OUTSET = 2; constexpr float FOCUS_ROW_SLACK = 4; constexpr float SEPARATOR_GAP = 4; constexpr float FOLD_ARROW = 3; constexpr float PREVIEW_CAPTION_GAP = 8; constexpr int CAPTION_LENGTH = 64; // The room between the preview's name and the top of the fighter under it. constexpr float PREVIEW_NAME_GAP = 4; constexpr float PREVIEW_SCALE = 3; constexpr const char PREVIEW_ANIMATION[] = "Preview"; constexpr float SUMMARY_ICON_GAP = 4; constexpr float NAME_PING_GAP = 3; // A queued game's area: its fill and edge, the queuer's icon over it (how high it floats and bobs, and how fast), and // the gap to the lines under it. constexpr float QUEUE_FILL_ALPHA = 0.12f; constexpr float QUEUE_EDGE_ALPHA = 0.6f; constexpr float QUEUE_ICON_RISE = 44; constexpr float QUEUE_ICON_BOB = 2; constexpr float QUEUE_ICON_SPEED = 3; constexpr float QUEUE_TEXT_GAP = 3; constexpr float FIELD_INSET = 4; constexpr float SLIDER_INSET = 3; constexpr float TEXT_INSET = (WIDGET_HEIGHT - LINE_HEIGHT) / 2; constexpr float HINT_FADE = 0.5f; // How much likelier a tip that fits what's at hand is to be picked than one that doesn't. constexpr float TIP_FIT_WEIGHT = 1.5f; // How long tips take to fade in and out. constexpr float TIP_FADE = 0.4f; constexpr const char* HINT_SPACING = " "; // How wide a row of first-run hints may run, clear of the hotbars in the corners. constexpr float HINT_ROW_WIDTH = 360; // Each first-run hint's control (Move takes Move Right too) and what it does. constexpr Control HINT_CONTROLS[HINT_COUNT] = {CONTROL_LEFT, CONTROL_JUMP, CONTROL_DOWN, CONTROL_ATTACK, CONTROL_CHARGE, CONTROL_MENU, CONTROL_READY, CONTROL_CHAT}; constexpr const char* HINT_LABELS[HINT_COUNT] = {"Move", "Jump", "Crouch / Drop", "Attack", "Charged Attack", "Menu", "Reset", "Chat"}; constexpr float TOOLTIP_OFFSET = 12; constexpr float TOOLTIP_PADDING = 6; constexpr float TABLE_GAP = 8; constexpr float TABLE_SPACE = 4; constexpr const char* TABLE_HEADERS[] = {"Attack", "Charged"}; // Stats round to hundredths. constexpr float HUNDREDTHS = 100; constexpr float TRACK_DOT = 6; constexpr float TRACK_HEIGHT = 2; constexpr float TRACK_WIDTH = 200; constexpr float SECONDS_PER_MINUTE = 60; // Slot numbers: their font and corner at each size (none on tiny slots). constexpr Font LABEL_FONTS[ICON_TINY] = {FONT_SMALL, FONT_TINY}; constexpr Vec2 LABEL_OFFSETS[ICON_TINY] = {{4, 2}, {3, 2}}; // Where a full slot's number sits from its icon's corner. constexpr Vec2 ICON_LABEL_OFFSET = {LABEL_OFFSETS[ICON_FULL].x - (SLOT_SIZE - ICON_SIZE) / 2, LABEL_OFFSETS[ICON_FULL].y - (SLOT_SIZE - ICON_SIZE) / 2}; // The situations a tip fits, combined as flags: playing online or offline, and the menu being open. enum TipContext { TIP_ONLINE = 1, TIP_OFFLINE = 2, TIP_MENU = 4 }; // A tip for the ready screen, and what it's about: a weapon or trinket by name, and the situations it fits (see // TipContext). One that fits what's at hand (that item equipped, or one of those situations) is TIP_FIT_WEIGHT times as // likely to be picked (see tip_pick). struct Tip { const char* text; const char* item = nullptr; int contexts = 0; }; // Tips shown under the ready button, one at random: movement and knockback, cooldowns and Charge, projectiles, each // Weapon and Trinket in turn, then the menus. constexpr Tip TIPS[] = { {"Getting knocked into walls hurts! Do your best to reduce your speed when attacked!"}, {"Don't get cornered! Knockback damage can add up fast!"}, {"Crouch to reduce knockback!"}, {"Crawl around by crouching and moving!"}, {"Roll around on sloped surfaces with Down!"}, {"Platforms block shots. Use them as cover!"}, {"Weapons don't share cooldowns! Switch Weapons to maximize damage!"}, {"When a Weapon enters cooldown, it will fade and return to normal when it's done!"}, {"Your cursor's color and vibration can show you your Weapon's cooldown or Charge!"}, {"Let go of a Charge early for a Basic Attack that hits harder!"}, {"Your Charge carries over when you swap Weapons!"}, {"Higher damaging attacks will glow and vibrate!"}, {"Going faster makes you glow and vibrate, showing that your attacks deal more damage!"}, {"Almost every attack counts as a projectile! See how you can change their traits with Trinkets!"}, {"Projectiles inherit your velocity. Go fast for big damage!"}, {"Projectiles lose damage the longer they're out. Get close!"}, {"Projectiles will fade with time; this fade shows their damage potential!"}, {"Increasing a projectile's lifetime or speed can both increase its range!"}, {"Explosions hit hardest at their center!"}, {"Your explosions don't hurt, and only push you. Try rocket jumping!"}, {"Recoil is movement. Fire away from where you want to go!"}, {"Mix and match Weapons and Trinkets for nigh-infinite amounts of combos!", nullptr, TIP_MENU}, {"The Fist's Charged Attack doesn't always send you straight up! Change your aim!", "Fist"}, {"Slam a Hammer into surfaces to fly far!", "Hammer"}, {"The Lance's Charged Attack lunges you forward!", "Lance"}, {"Crack the Whip against a wall to be pulled toward it!", "Whip"}, {"The Whip's Charged Attack only blasts at the very tip, but does big damage!", "Whip"}, {"Charge a Flail to throw its ball, and Charge again to reel it back!", "Flail"}, {"You can have the Flail's ball extended and another Weapon equipped for a dangerous combo!", "Flail"}, {"Charged Boomerangs are platforms only you can ride!", "Boomerang"}, {"Charge recalls your Boomerangs!", "Boomerang"}, {"A Grapple's Charged Attack yanks you to your target!", "Grapple"}, {"Swing around on or release a latched Grapple for big speed!", "Grapple"}, {"You can have a Grapple latched and another Weapon equipped for an acrobatic fight!", "Grapple"}, {"A Bow's arrows stuck in walls are semi-solid platforms!", "Bow"}, {"The Bow's arrows fly faster and further with Charge!", "Bow"}, {"Fire a wide volley with the Uzi's Charged Attack!", "Uzi"}, {"Use a Shotgun's recoil for great movement!", "Shotgun"}, {"The Bolter's projectiles can cling onto surfaces, perfect for area denial!", "Bolter"}, {"Cook a Grenade Launcher's grenade via Charging to explode it sooner!", "Grenade Launcher"}, {"Fully Charging a Grenade Launcher will cause an explosion in your face!", "Grenade Launcher"}, {"Hit the Grenade Launcher's projectile and explosion at the same time for big damage! Double Donk!", "Grenade Launcher"}, {"Mines need a moment to arm before they'll go off!", "Mine"}, {"The Flamethrower's Charged Attack detonates mid-air! Space an explosion!", "Flamethrower"}, {"The Vacuum's Charged Attack deals big knockback! Send foes flying!", "Vacuum"}, {"The Vacuum's Charged Attack turns enemy shots into your own!", "Vacuum"}, {"Hold your own projectiles in the Vacuum's suction to keep them fresh!", "Vacuum"}, {"The Vacuum's suction and Charged Attack can move projectiles around. Manipulate the battlefield!", "Vacuum"}, {"Use the Grimoire's wisps as a close-range attack or to pepper from afar!", "Grimoire"}, {"Use the Grimoire's teleporting Charged Attack to escape danger!", "Grimoire"}, {"Diazepam reduces Weapon spread, perfect for concentrated attacks!", "Diazepam"}, {"Splitter copies fan wider with each level!", "Splitter"}, {"The off-hand Weapon from Dual Wield is the one you last switched from!", "Dual Wield"}, {"Ricochet makes projectiles deal more damage the more they've bounced!", "Ricochet"}, {"Ricochet bounces reset a projectile's lifetime!", "Ricochet"}, {"Tesla Coil arcs electricity between your projectiles. Make traps or wide danger zones!", "Tesla Coil"}, {"The kickback the Recoil Coil gives is perfect for movement!", "Recoil Coil"}, {"Stay in place with a Shock Absorber!", "Shock Absorber"}, {"Embiggener makes you a bigger target, but gives a solid damage buff!", "Embiggener"}, {"Make yourself a living hazard with a Pebble Helmet!", "Pebble Helmet"}, {"With a Speedometer, moving fast and attacking deals bigger damage!", "Speedometer"}, {"A pair of Wings will make you dash to where you're aiming when you jump mid-air!", "Wings"}, {"With the Feather, hold Up in the air to float!", "Feather"}, {"With Iron Weights, faster falls produce bigger blasts!", "Iron Weights"}, {"Cling, slide, and kick off walls with a Climb Claw!", "Climb Claw"}, {"Create a Lobby and share its link or code with friends for multiplayer fun!", nullptr, TIP_OFFLINE}, {"When in a Lobby, queue up a game in the menu!", nullptr, TIP_ONLINE}, {"Queuing takes place in the Sandbox! Holler fighters over to your queued area and Ready up!", nullptr, TIP_ONLINE}, {"Change a game's rules underneath the Rules option!", nullptr, TIP_MENU}, {"If a game has a time limit, the winner will be decided based on who has the most health!"}, {"Is your name \"Fighter\"? Change it in Profile!", nullptr, TIP_MENU}, {"Change your fighter and HUD color in Profile!", nullptr, TIP_MENU}, {"Feeling naked and unsexy? Wear some Fashion within your Profile!", nullptr, TIP_MENU}, {"Set and load custom gear Loadouts in your Profile!", nullptr, TIP_MENU}, {"Save a gear Loadout and use Shift + a number to equip it!", nullptr, TIP_MENU}, {"Turn on DPS in Settings to see the damage you deal each second!", nullptr, TIP_MENU}, {"Turn on Speed in Settings to see how fast you're going!", nullptr, TIP_MENU}, {"Turn on FPS in Settings to see how smoothly the game runs!", nullptr, TIP_MENU}, {"Is previewing your aim too intrusive? Toggle Aim Preview off in Settings!", nullptr, TIP_MENU}, {"Want to see how things actually collide? Toggle Display Collision in Settings!", nullptr, TIP_MENU}, {"Game too loud? Turn down the Volume or Mute it in Settings!", nullptr, TIP_MENU}, {"Don't like the Controls? Tweak them to your liking in Settings!", nullptr, TIP_MENU}, {"Want to experiment with more Weapon or Trinket slots in the Sandbox? Change them in Settings!", nullptr, TIP_MENU}, {"Want to fight in the Sandbox with others? Check PvP in Settings or use the /pvp command!", nullptr, TIP_ONLINE}, {"The Dummy in the Sandbox doesn't feel pain! Beat it as much as you'd like, it enjoys it!"}, {"Throw tomatoes to mock a game's winner in between rounds by clicking anywhere on screen!"}, {"Sling slurs and trashtalk all you wish with a Chat message!", nullptr, TIP_ONLINE}, {"A number of useful / commands are present in Chat! Type /help to see!", nullptr, TIP_ONLINE}, {"Want to see the sexy person who made this game? Check About in the menu!", nullptr, TIP_MENU}, }; static CountdownInfo countdown_info_get(Game& game); static float rival_ping_get(Game& game, int fighter); // How opaque a HUD element (other than a hotbar slot) is, fading as the mouse comes over it. static float hover_alpha_get(float hover) { return 1 - (1 - HUD_HOVER_ALPHA) * hover; } void Ui::init(Game& game) { for (int i = 0; i < MAX_FIGHTERS; i++) { SDL_snprintf(botNames[i], sizeof(botNames[i]), "Bot %d", i + 1); SDL_snprintf(playerNames[i], sizeof(playerNames[i]), "Player %d", i + 1); } } // The name shown for a fighter: the screen name for this screen's own, and "Bot" with its number for the rest. const char* Ui::name_get(Game& game, int fighter) { // Online, a player's name, numbered when someone earlier in the game has the same one ("Fighter", "Fighter (2)"). if (game.session.isActive && game.sim.match.owners[fighter] >= 0) { std::string name = game.session.base_name_get(game, game.sim.match.owners[fighter]); int same = 1; for (int i = 0; i < fighter; i++) same += game.sim.match.owners[i] >= 0 && game.session.base_name_get(game, game.sim.match.owners[i]) == name; if (same > 1) name += " (" + std::to_string(same) + ")"; SDL_strlcpy(onlineNames[fighter], name.c_str(), sizeof(onlineNames[fighter])); return onlineNames[fighter]; } if (fighter == game.local) return game.settings.name; return game.is_local(fighter) ? playerNames[fighter] : botNames[fighter]; } void Ui::draw(Game& game) { // Watching a queued game, nothing of it is this screen's own: the HUD is white, and there's no hotbar. bool isWatching = game.session.is_watching(game); tint = isWatching ? WHITE : game.color_get(game.local, TINT_PLAIN); shade = color_shade(tint, SECONDARY_SHADE); Vec2 mouse = game.input.mouse; // The menu covers the match overlays. Hotbars sit under them (hidden while the menu is up, except on its item tabs, // which they slide under). With several players on this screen in a match, each gets a compact panel along the // bottom instead. statusRect = {}; queues_draw(game); bars_draw(game); game.chat.overhead_draw(game); Vec2 camera = game.renderer.camera; game.renderer.camera = {}; offscreen_draw(game, camera); bool isBarsRaised = game.menu.is_bars_open(); bool isShared = game.sim.match.state != MATCH_PRACTICE && game.local_count() > 1; if (!game.menu.isOpen && !isShared && !isWatching) { cooldowns_draw(game); hotbar_draw(game); } if (isShared) panels_draw(game); else if (!isWatching) status_draw(game); match_draw(game); if (!isShared) { rivals_draw(game); spectate_draw(game); } game.chat.draw(game); game.menu.draw(game); if (game.sim.match.state == MATCH_COUNTDOWN && game.menu.isOpen && !isShared) countdown_aside_draw(game); else if (game.menu.isOpen) tip_draw(game, tip_low_get()); if (isBarsRaised) hotbar_draw(game); crosshairs_draw(game, camera); queue_tooltip_draw(game); if (active && !isActiveSeen) widget_release(game); isActiveSeen = false; } // Stacked above each living fighter: in a deathmatch its health bar, then (in a match, or a lobby's sandbox with others // in it) its name in its color, then the local fighter's charge bar while charging, which blinks once full. void Ui::bars_draw(Game& game) { Match& match = game.sim.match; Renderer& renderer = game.renderer; for (int i = 0; i < game.sim.fighterCount; i++) { Fighter& fighter = game.sim.fighters[i]; if (!fighter.isAlive) continue; Rect box = fighter.body.box; float centerX = box.x + box.w / 2; float bottom = std::round(game.scene.top_get(fighter) - BAR_GAP); Color color = game.color_get(i); bool isMatch = match.state != MATCH_PRACTICE; if (isMatch && !match.is_racing()) { float maxHealth = match.health_get(); Rect bar = {std::round(centerX - HEALTH_BAR_WIDTH / 2), bottom - HEALTH_BAR_HEIGHT, HEALTH_BAR_WIDTH, HEALTH_BAR_HEIGHT}; bar_draw(renderer, bar, std::max(0.0f, fighter.health) / maxHealth, color); bottom = bar.y - BAR_GAP; } // Names show in a match, and in a lobby's sandbox once there's someone else to tell apart. bool isNamed = isMatch || (game.session.isActive && game.sim.fighterCount > 1); if (isNamed) { // Online, the player's connection beside it, the two centerd together. const char* name = name_get(game, i); Vec2 size = game.text.size_get(name); bottom -= size.y; float ping = rival_ping_get(game, i); float width = size.x + (ping == PING_NONE ? 0 : NAME_PING_GAP + ping_width_get(game, ping)); float left = std::round(centerX - width / 2); game.text.draw(renderer, name, {.color = color, .position = {left, bottom}}); if (ping != PING_NONE) ping_draw(game, {left + size.x + NAME_PING_GAP, bottom}, ping, color.a); bottom -= BAR_GAP; } if (i != game.local || fighter.charge < CHARGE_VISIBLE || game.session.is_watching(game)) continue; float fill = (fighter.charge - CHARGE_VISIBLE) / (1 - CHARGE_VISIBLE); bool isShown = fighter.charge < 1 || game.scene.is_flashing(fighter); Rect bar = {std::round(centerX - CHARGE_BAR_WIDTH / 2), bottom - CHARGE_BAR_HEIGHT, CHARGE_BAR_WIDTH, CHARGE_BAR_HEIGHT}; bar_draw(renderer, bar, isShown ? fill : 0, color); } } // A bar, its edge stroke thick, filled from the left by fill, from 0 to 1. void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color, float stroke) { panel_draw(renderer, bar, color, -1, stroke); renderer.rect_draw({bar.x + stroke, bar.y + stroke, std::round((bar.w - stroke * 2) * fill), bar.h - stroke * 2}, color); } // In practice, the first-run hints (see hints_draw). In a match, the round wins and // clock along the top, and a banner for the countdown (with who's ready, a Ready button and a tip), the start of the // fight, the end of a round and the end of the match. void Ui::match_draw(Game& game) { Match& match = game.sim.match; char text[TOOLTIP_LENGTH]; Input& input = game.input; if (match.state == MATCH_PRACTICE) { if (!game.menu.isOpen) hints_draw(game); return; } bool isRace = match.rules.mode == MODE_RACE; if (isRace) race_draw(game); else { // The score: each fighter's wins in its own color, the dashes between them white. It's centerd along the top, // unless a long weapon hotbar reaches it in the HUD, when it moves right to clear the hotbar (the clock under // it moves with it). It stays put while the menu moves the hotbar. Renderer& renderer = game.renderer; int length = 0; for (int i = 0; i < game.sim.fighterCount; i++) length += SDL_snprintf(text + length, sizeof(text) - length, i > 0 ? " - %d" : "%d", match.wins[i]); Vec2 scale = {SCORE_SCALE, SCORE_SCALE}; float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x; int weapons = game.local_get().weaponSlots; Rect last = game.menu.slot_hud_rect(ITEM_WEAPON, weapons - 1, weapons); float hotbarRight = last.x + last.w; float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP)); float center = x + scoreWidth / 2; for (int i = 0; i < game.sim.fighterCount; i++) { char piece[TOOLTIP_LENGTH]; if (i > 0) { game.text.draw(renderer, " - ", {.color = WHITE, .position = {x, HOTBAR_MARGIN}, .scale = scale}); x += game.text.size_get(" - ", FONT_SMALL, SCORE_SCALE).x; } SDL_snprintf(piece, sizeof(piece), "%d", match.wins[i]); game.text.draw(renderer, piece, {.color = game.color_get(i), .position = {x, HOTBAR_MARGIN}, .scale = scale}); x += game.text.size_get(piece, FONT_SMALL, SCORE_SCALE).x; } // The round's clock, in white: counting down to the time limit, or up when there's none. float limit = match.time_limit_get(); int seconds = std::ceil(limit > 0 ? std::max(0.0f, limit - match.clock) : match.clock); SDL_snprintf(text, sizeof(text), "%d:%02d", seconds / (int)SECONDS_PER_MINUTE, seconds % (int)SECONDS_PER_MINUTE); float width = game.text.size_get(text).x; game.text.draw(renderer, text, {.color = WHITE, .position = {std::round(center - width / 2), HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE)}}); } countdown_beep(game); switch (match.state) { case MATCH_COUNTDOWN: { // The arena and HUD dim behind the countdown. float fade = std::min(1.0f, (match.countdown - match.timer) / LOADOUT_FADE); game.renderer.rect_draw({0, 0, CANVAS_WIDTH, CANVAS_HEIGHT}, color_fade(BLACK, COUNTDOWN_DIM * fade)); float y = COUNTDOWN_Y; // A spectator only sees who's ready, with nothing of their own to ready or show. CountdownInfo info = countdown_info_get(game); bool isWatching = game.session.is_watching(game); bool isLocalReady = match.isReady[game.local] || isWatching; if (game.menu.isOpen) break; SDL_snprintf(text, sizeof(text), "%s %s", info.round, info.time); text_center_draw(game, text, y, ROUND_SCALE); y += game.text.line_height_get(FONT_SMALL, ROUND_SCALE) + READY_GAP; SDL_snprintf(text, sizeof(text), isLocalReady ? "%d/%d Ready" : "%d/%d Ready - Press %s to Ready Up", info.ready, game.sim.fighterCount, info.key); text_center_draw(game, text, y, 1); y += LINE_HEIGHT + READY_GAP; if (isWatching) break; if (game.local_count() > 1 && match.is_choosing()) { picks_draw(game, y); break; } widgets_begin(1, !game.menu.isOpen); widgets_enable(!isLocalReady); Rect button = {std::round((CANVAS_WIDTH - READY_BUTTON_WIDTH) / 2), y, READY_BUTTON_WIDTH, READY_BUTTON_HEIGHT}; input.devices[game.devices[game.local]].isReady |= button_draw(game, button, isLocalReady ? "Ready!" : "Ready"); widgets_enable(true); y += READY_BUTTON_HEIGHT + READY_GAP; tip_draw(game, y); y += LINE_HEIGHT + TIP_GAP; loadoutTop = y; if (!game.menu.isOpen && game.local_count() == 1) loadout_draw(game, fade, y); break; } case MATCH_FIGHT: // The loadout fades out under the banner. if (match.timer > -LOADOUT_FADE && game.local_count() == 1 && !game.session.is_watching(game)) { float fade = 1 + match.timer / LOADOUT_FADE; game.renderer.rect_draw({0, 0, CANVAS_WIDTH, CANVAS_HEIGHT}, color_fade(BLACK, COUNTDOWN_DIM * fade)); loadout_draw(game, fade, loadoutTop); } if (match.timer > -FIGHT_BANNER_TIME) text_center_draw(game, isRace ? "Go!" : "Fight!", BANNER_Y, BANNER_SCALE); // A timed round's last seconds count down big. if (match.time_limit_get() > 0 && countdownBeeped > 0) { SDL_snprintf(text, sizeof(text), "%d", countdownBeeped); text_center_draw(game, text, BANNER_Y, BANNER_SCALE); } break; case MATCH_ROUND_OVER: if (match.winner >= 0) SDL_snprintf(text, sizeof(text), "%s Wins!", name_get(game, match.winner)); else SDL_strlcpy(text, "Draw", sizeof(text)); // A win is announced in the winner's color. text_center_draw(game, text, BANNER_Y, ROUND_SCALE, match.winner >= 0 ? game.color_get(match.winner) : tint); break; case MATCH_OVER: { if (match.winner >= 0) SDL_snprintf(text, sizeof(text), isRace ? "%s Wins!" : "%s Won the Match!", name_get(game, match.winner)); else SDL_strlcpy(text, "Nobody Finished", sizeof(text)); text_center_draw(game, text, BANNER_Y, BANNER_SCALE, match.winner >= 0 ? game.color_get(match.winner) : tint); float y = BANNER_Y + game.text.line_height_get(FONT_SMALL, BANNER_SCALE); if (isRace) y = results_draw(game, y); // The menu control leaves, as a browser keeps Escape for leaving fullscreen. char leave[TOOLTIP_LENGTH]; input.control_name_get(CONTROL_MENU, leave, sizeof(leave)); if (!game.session.isActive) { SDL_snprintf(text, sizeof(text), "%s: Sandbox", leave); text_center_draw(game, text, y, 1); break; } // Online, a queued game's players each say whether to play it again (listed under), or leave. if (!game.session.is_watching(game)) { char key[TOOLTIP_LENGTH]; input.control_name_get(CONTROL_READY, key, sizeof(key)); SDL_snprintf(text, sizeof(text), "%s: Play Again %s: Leave", key, leave); text_center_draw(game, text, y, 1); y += LINE_HEIGHT * 2; } for (int i = 0; i < game.sim.fighterCount; i++) { if (match.owners[i] < 0 || match.isGone[i]) continue; SDL_snprintf(text, sizeof(text), "- %s%s", name_get(game, i), match.againTicks > 0 ? " [Starting!]" : match.isReady[i] ? " [Play Again]" : ""); text_center_draw(game, text, y, 1, game.color_get(i, TINT_PLAIN)); y += LINE_HEIGHT; } break; } default: break; } } // Minutes, seconds and hundredths, like 1:05.25. static void time_format(char* text, int size, float seconds) { int minutes = seconds / SECONDS_PER_MINUTE; SDL_snprintf(text, size, "%d:%05.2f", minutes, seconds - minutes * SECONDS_PER_MINUTE); } // Along the top of a race: the clock, and a track with a dot in each racer's color showing how far along the course // it is. void Ui::race_draw(Game& game) { char text[TOOLTIP_LENGTH]; time_format(text, sizeof(text), game.sim.match.clock); text_center_draw(game, text, HOTBAR_MARGIN, SCORE_SCALE); float left = std::round((CANVAS_WIDTH - TRACK_WIDTH) / 2); float y = HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE) + TRACK_DOT; game.renderer.rect_draw({left, y - TRACK_HEIGHT / 2, TRACK_WIDTH, TRACK_HEIGHT}, shade); float length = game.sim.world.size_get().x; for (int i = 0; i < game.sim.fighterCount; i++) { Fighter& fighter = game.sim.fighters[i]; float progress = game.sim.match.finishTimes[i] >= 0 ? 1 : std::clamp(fighter.center_get().x / length, 0.0f, 1.0f); float x = std::round(left + progress * TRACK_WIDTH - TRACK_DOT / 2); Rect dot = {x, std::round(y - TRACK_DOT / 2), TRACK_DOT, TRACK_DOT}; game.renderer.rect_draw(dot, game.color_get(i)); } } // The race's finishing order from y down, one line per racer in its color, with its time (or DNF). Returns the y // below the last line. float Ui::results_draw(Game& game, float y) { Match& match = game.sim.match; int order[MAX_FIGHTERS]; for (int i = 0; i < game.sim.fighterCount; i++) order[i] = i; auto rank = [&](int fighter) { return match.finishTimes[fighter] >= 0 ? match.finishTimes[fighter] : INFINITY; }; std::stable_sort(order, order + game.sim.fighterCount, [&](int a, int b) { return rank(a) < rank(b); }); for (int place = 0; place < game.sim.fighterCount; place++) { int fighter = order[place]; char time[TOOLTIP_LENGTH]; char text[TOOLTIP_LENGTH]; if (match.finishTimes[fighter] >= 0) time_format(time, sizeof(time), match.finishTimes[fighter]); else SDL_strlcpy(time, "DNF", sizeof(time)); SDL_snprintf(text, sizeof(text), "%d. %s %s", place + 1, name_get(game, fighter), time); float width = game.text.size_get(text).x; game.text.draw(game.renderer, text, {.color = game.color_get(fighter), .position = {std::round((CANVAS_WIDTH - width) / 2), y}}); y += LINE_HEIGHT; } return y; } // A trinket stat's value as its tooltip shows it (see StatFormat), multipliers to two decimals like weapon stats. static void trinket_stat_format(char* text, int size, TrinketStat stat, float value) { constexpr const char* CLIMBS[] = {"None", "Cling", "Cling, Kick", "Cling, Kick, Jump"}; StatFormat format = TRINKET_STATS[stat].format; float times = std::round((format == FORMAT_LEFT ? 1 - value : value) * HUNDREDTHS) / HUNDREDTHS; switch (format) { case FORMAT_TIMES: case FORMAT_LEFT: SDL_snprintf(text, size, "%gx", times); break; case FORMAT_PLUS: SDL_snprintf(text, size, "+%gx", times); break; case FORMAT_NUMBER: SDL_snprintf(text, size, "%g", value); break; case FORMAT_UNITS: SDL_snprintf(text, size, "%gu", value); break; case FORMAT_CLIMB: SDL_strlcpy(text, CLIMBS[std::clamp((int)value, 0, (int)std::size(CLIMBS) - 1)], size); break; } } // A trinket's stats table: a row for each stat it has at its level, previewing the next level's value after an arrow // where it changes (the catalog). static void trinket_table_build(int trinket, int level, TooltipMode mode, Tooltip& tooltip) { const TrinketDef& def = TRINKET_DEFS[trinket]; bool isPreview = mode == TOOLTIP_NEXT_LEVEL; tooltip.rowCount = 0; for (int stat = 0; stat < STAT_COUNT && tooltip.rowCount < STAT_ROWS; stat++) { float now = def.levels[level].*TRINKET_STATS[stat].field; float next = isPreview ? def.levels[level + 1].*TRINKET_STATS[stat].field : now; if (now == 0 && next == 0) continue; StatRow& row = tooltip.rows[tooltip.rowCount++]; row = {.label = TRINKET_STATS[stat].label}; trinket_stat_format(row.values[0], STAT_LENGTH, (TrinketStat)stat, now); if (next == now) continue; char after[STAT_LENGTH]; trinket_stat_format(after, sizeof(after), (TrinketStat)stat, next); int length = SDL_strlen(row.values[0]); SDL_snprintf(row.values[0] + length, STAT_LENGTH - length, " → %s", after); } } // A trinket's stats at a level as lines ("Attack Rate 1.15x"), for where there's no room for its table. static void trinket_lines_get(int trinket, int level, char* text, int size) { Tooltip tooltip; trinket_table_build(trinket, level, TOOLTIP_PLAIN, tooltip); for (int i = 0; i < tooltip.rowCount; i++) { int length = SDL_strlen(text); SDL_snprintf(text + length, size - length, "\n%s %s", tooltip.rows[i].label, tooltip.rows[i].values[0]); } } // A weapon's level and its main attack in one line for the round's loadout overview, like "26 dmg, every 0.7s, 320u" // (the charged attack when the plain one doesn't hurt, as a mine's detonator doesn't). static void compact_stats_get(Game& game, Fighter& fighter, int weapon, int level, char* text, int size) { AttackSummary summary = attack_summary_get(fighter, weapon, level, VARIANT_NORMAL); if (!summary.isDamaging) summary = attack_summary_get(fighter, weapon, level, VARIANT_CHARGED); char every[STAT_LENGTH]; SDL_snprintf(every, sizeof(every), "%g", std::round(summary.every * 100) / 100); int length = SDL_snprintf(text, size, "(Level %d)\n%g dmg%s, every %ss", level + 1, summary.damage, summary.pieces > 1 ? " each" : "", every); if (summary.range > 0 && !std::isinf(summary.range)) SDL_snprintf(text + length, size - length, ", %gu", std::round(summary.range)); } // How wide the tooltip's text runs in the lines beside its icon (those starting within the icon's height), which // leave room for it. static float beside_icon_get(Game& game, Tooltip& tooltip, Font font) { float lineHeight = game.text.line_height_get(font); float width = 0; float y = 0; for (const char* part : {tooltip.name, (const char*)tooltip.stats, (const char*)tooltip.description}) { for (const char* start = part; *part && y < ICON_SIZE;) { const char* end = SDL_strchr(start, '\n'); char line[TOOLTIP_LENGTH]; SDL_strlcpy(line, start, std::min((int)sizeof(line), (int)((end ? end - start : SDL_strlen(start)) + 1))); width = std::max(width, game.text.size_get(line, font).x); y += lineHeight; if (!end) break; start = end + 1; } } return width; } // The local fighter's loadout laid out in a centerd grid between top and bottom: weapons and trinkets side by side // when they all fit in one row, otherwise weapons above trinkets, a kind's panels wrapping onto more rows as needed, // with a line between the two kinds (down between them, or across). Every panel is as wide as the widest so they line // up, and a row as tall as its kind's tallest. It never moves up over what's above it: it takes the fullest style that // fits, from names, levels, trinket effects and descriptions (weapon numbers and flavor quotes left out), the same in // the small font, then names and levels beside their icons (in either font), down to just the icons, which always fit. LoadoutLayout Ui::loadout_layout(Game& game, float top, float bottom) { Fighter& fighter = game.local_get(); int counts[2] = {fighter.weaponSlots, fighter.trinketSlots}; float space = CANVAS_WIDTH - HOTBAR_MARGIN * 2; LoadoutLayout layout; for (int style = 0; style < LOADOUT_STYLE_COUNT; style++) { layout.style = style; bool isBrief = style == LOADOUT_BRIEF || style == LOADOUT_BRIEF_SMALL; Font font = style == LOADOUT_FULL_SMALL || style == LOADOUT_BRIEF_SMALL ? FONT_TINY : FONT_SMALL; float width = SLOT_SIZE; float heights[2] = {SLOT_SIZE, SLOT_SIZE}; for (ItemKind kind : ITEM_KINDS) { if (style == LOADOUT_ICONS) break; for (int i = 0; i < counts[kind]; i++) { int item = fighter.loadout_get(kind)[i]; if (item < 0) continue; int level = fighter.slot_levels_get(kind)[i]; Tooltip& tooltip = layout.tooltips[kind][i] = tooltip_build(game, kind, item, level); tooltip.slot = i; tooltip.flavor[0] = '\0'; tooltip.rowCount = 0; if (kind == ITEM_TRINKET && !isBrief) { SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)", level + 1); trinket_lines_get(item, level, tooltip.stats, sizeof(tooltip.stats)); } else if (!isBrief) compact_stats_get(game, fighter, item, level, tooltip.stats, sizeof(tooltip.stats)); else SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)", level + 1); if (isBrief) tooltip.description[0] = '\0'; // The icon sits in the top-right corner, beside the first lines. Vec2 measured = tooltip_size(game, tooltip, font); width = std::max({width, measured.x + TOOLTIP_PADDING * 2, beside_icon_get(game, tooltip, font) + ICON_SIZE + TOOLTIP_PADDING * 3}); heights[kind] = std::max({heights[kind], measured.y + TOOLTIP_PADDING * 2, ICON_SIZE + TOOLTIP_PADDING * 2}); } } // A line parts weapons from trinkets: down between them when they share a row, across between their rows // otherwise. // (only when both kinds have something to show). auto is_filled = [&](int* loadout, int count) { return std::any_of(loadout, loadout + count, [](int item) { return item >= 0; }); }; bool isDivided = is_filled(fighter.weaponLoadout, counts[0]) && is_filled(fighter.trinketLoadout, counts[1]); float divide = isDivided ? LOADOUT_DIVIDER + LOADOUT_GAP : 0; int fit = std::max(1, (int)((space - divide + LOADOUT_GAP) / (width + LOADOUT_GAP))); bool isShared = fit >= counts[0] + counts[1]; int columns = isShared ? counts[0] + counts[1] : std::min(fit, std::max(counts[0], counts[1])); float gridWidth = width * columns + LOADOUT_GAP * (columns - 1) + (isShared ? divide : 0); float rowTop = top; layout.divider = {}; for (ItemKind kind : ITEM_KINDS) { float height = isShared ? std::max(heights[0], heights[1]) : heights[kind]; for (int i = 0; i < counts[kind]; i++) { int cell = isShared ? kind * counts[0] + i : i; float shift = isShared && kind == ITEM_TRINKET ? divide : 0; layout.panels[kind][i] = {cell % columns * (width + LOADOUT_GAP) + shift, rowTop + cell / columns * (height + LOADOUT_GAP), width, height}; } if (isShared && kind == ITEM_WEAPON && isDivided) layout.divider = {counts[0] * (width + LOADOUT_GAP), top, LOADOUT_DIVIDER, height}; if (isShared) continue; rowTop += (counts[kind] + columns - 1) / columns * (height + LOADOUT_GAP); if (kind == ITEM_WEAPON && isDivided) { layout.divider = {0, rowTop, gridWidth, LOADOUT_DIVIDER}; rowTop += divide; } } float left = std::round((CANVAS_WIDTH - gridWidth) / 2); layout.divider.x += left; float gridBottom = top; for (ItemKind kind : ITEM_KINDS) for (int i = 0; i < counts[kind]; i++) { layout.panels[kind][i].x += left; gridBottom = std::max(gridBottom, layout.panels[kind][i].y + layout.panels[kind][i].h); } layout.bounds = {left, top, gridWidth, gridBottom - top}; if (gridWidth <= space && gridBottom <= bottom) break; } return layout; } // The loadout overview (see loadout_layout) from top down, faded by alpha. void Ui::loadout_draw(Game& game, float alpha, float top) { Fighter& fighter = game.local_get(); LoadoutLayout layout = loadout_layout(game, top, CANVAS_HEIGHT - HOTBAR_MARGIN); int counts[2] = {fighter.weaponSlots, fighter.trinketSlots}; Font font = layout.style == LOADOUT_FULL_SMALL || layout.style == LOADOUT_BRIEF_SMALL ? FONT_TINY : FONT_SMALL; Color color = color_fade(tint, alpha); if (layout.divider.w > 0) game.renderer.rect_draw(layout.divider, color); for (ItemKind kind : ITEM_KINDS) { for (int i = 0; i < counts[kind]; i++) { int item = fighter.loadout_get(kind)[i]; if (item < 0) continue; Rect panel = layout.panels[kind][i]; if (layout.style == LOADOUT_ICONS) { slot_draw(game, {.rect = panel, .alpha = alpha, .item = item, .level = fighter.slot_levels_get(kind)[i], .isLit = true, .kind = kind}); continue; } panel_draw(game.renderer, panel, color); tooltip_text_draw(game, layout.tooltips[kind][i], {.color = color, .position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}}, font); tooltip_icon_draw(game, layout.tooltips[kind][i], {.color = color, .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); } } } // Text centerd across the canvas with its top at y. // What the countdown shows: the round's name, whole seconds left (or Starting... once everyone's ready), how many are // ready, and the key that readies up. static CountdownInfo countdown_info_get(Game& game) { Match& match = game.sim.match; CountdownInfo info; if (match.rules.mode == MODE_RACE) SDL_strlcpy(info.round, "Race", sizeof(info.round)); else if (match.is_final_round(game.sim)) SDL_strlcpy(info.round, "Final Round", sizeof(info.round)); else SDL_snprintf(info.round, sizeof(info.round), "Round %d", match.round + 1); for (int i = 0; i < game.sim.fighterCount; i++) info.ready += match.isReady[i]; if (info.ready == game.sim.fighterCount) SDL_strlcpy(info.time, "Starting...", sizeof(info.time)); else SDL_snprintf(info.time, sizeof(info.time), "%d", (int)std::ceil(match.timer)); game.input.control_name_get(CONTROL_READY, info.key, sizeof(info.key)); return info; } // Tips show on the ready screen and while the menu is up, from one continuous run that turns over every TIP_DURATION // whether or not they're showing. Each fits what's at hand when it's picked (the first, on the first tick). void Ui::tips_update(Game& game) { tipAge += TICK; if (tipAge < TIP_DURATION) return; tip = tip_pick(game); tipAge = 0; } // The tip showing (unless tips are turned off), centered at y in small text, fading in as it comes and out before the // next. void Ui::tip_draw(Game& game, float y) { if (!game.settings.isTipsShown) return; char text[TOOLTIP_LENGTH]; SDL_snprintf(text, sizeof(text), "Tip: %s", TIPS[tip].text); float alpha = std::clamp(std::min(tipAge, TIP_DURATION - tipAge) / TIP_FADE, 0.0f, 1.0f); text_center_draw(game, text, y, 1, alpha); } // Where a tip goes under the menu: below the hotbars' row (and the gamepad hints, when they show). float Ui::tip_low_get() { return BAR_Y + SLOT_SIZE + HINT_GAP + (isFocusShown ? LINE_HEIGHT : 0); } // Picks the next tip (never the one showing), at random but TIP_FIT_WEIGHT times as likely for one that fits what's at // hand: about a weapon or trinket this screen's fighter has equipped, or fitting how it's playing (online or offline) // or the menu being open. int Ui::tip_pick(Game& game) { Fighter& local = game.local_get(); int contexts = (game.session.isActive ? TIP_ONLINE : TIP_OFFLINE) | (game.menu.isOpen ? TIP_MENU : 0); auto is_equipped = [&](const char* name) { for (ItemKind kind : ITEM_KINDS) { int item = item_find(kind, name); int* loadout = local.loadout_get(kind); int slots = local.slots_get(kind); if (item >= 0 && std::find(loadout, loadout + slots, item) != loadout + slots) return true; } return false; }; float weights[std::size(TIPS)]; float total = 0; for (size_t i = 0; i < std::size(TIPS); i++) { const Tip& candidate = TIPS[i]; bool isFit = (candidate.contexts & contexts) || (candidate.item && is_equipped(candidate.item)); weights[i] = (int)i == tip ? 0 : isFit ? TIP_FIT_WEIGHT : 1; total += weights[i]; } float roll = random_get(0, total); for (size_t i = 0; i < std::size(TIPS); i++) { roll -= weights[i]; if (roll < 0) return i; } return std::size(TIPS) - 1; } // Marks a first-run hint learned once this screen's fighter has done what it says (a full charge for the charged // attack), keeping it in the settings across launches, and fades learned ones out. void Ui::hints_update(Game& game) { Fighter& local = game.local_get(); int& learned = game.settings.hintsLearned; bool isDone[HINT_COUNT] = { local.is_down(CONTROL_LEFT) || local.is_down(CONTROL_RIGHT), local.is_down(CONTROL_JUMP), local.is_down(CONTROL_DOWN), local.is_down(CONTROL_ATTACK), local.charge >= 1, game.menu.isOpen, local.is_down(CONTROL_READY), game.chat.isOpen, }; for (int i = 0; i < HINT_COUNT; i++) { int bit = 1 << i; if (isDone[i] && !(learned & bit) && !game.session.is_watching(game)) { learned |= bit; game.settings.changed(); } hintAlphas[i] = learned & bit ? std::max(0.0f, hintAlphas[i] - TICK / HINT_FADE) : 1; } } // The first-run hints still showing, each with its key, centerd in rows along the bottom of the sandbox, a row going up // when they'd run too wide for one. void Ui::hints_draw(Game& game) { Input& input = game.input; char texts[HINT_COUNT][TOOLTIP_LENGTH]; float widths[HINT_COUNT]; int shown[HINT_COUNT]; int count = 0; for (int i = 0; i < HINT_COUNT; i++) { if (hintAlphas[i] <= 0 || (i == HINT_CHAT && !game.session.isActive)) continue; char key[TOOLTIP_LENGTH]; char other[TOOLTIP_LENGTH]; input.control_name_get(HINT_CONTROLS[i], key, sizeof(key)); input.control_name_get(CONTROL_RIGHT, other, sizeof(other)); if (i == HINT_MOVE) SDL_snprintf(texts[count], sizeof(texts[count]), "%s/%s: %s", key, other, HINT_LABELS[i]); else SDL_snprintf(texts[count], sizeof(texts[count]), i == HINT_CHARGE ? "Hold %s: %s" : "%s: %s", key, HINT_LABELS[i]); widths[count] = game.text.size_get(texts[count]).x; shown[count++] = i; } // Rows fill greedily, then stack up from the bottom. float gap = game.text.size_get(HINT_SPACING).x; float limit = HINT_ROW_WIDTH; int rowStarts[HINT_COUNT + 1]; int rows = 0; float width = limit; for (int i = 0; i < count; i++) { if (width + gap + widths[i] > limit) { rowStarts[rows++] = i; width = widths[i]; continue; } width += gap + widths[i]; } rowStarts[rows] = count; for (int row = 0; row < rows; row++) { float total = -gap; for (int i = rowStarts[row]; i < rowStarts[row + 1]; i++) total += widths[i] + gap; float x = std::round((CANVAS_WIDTH - total) / 2); float y = CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT * (rows - row); for (int i = rowStarts[row]; i < rowStarts[row + 1]; i++) { game.text.draw(game.renderer, texts[i], {.color = color_fade(tint, hintAlphas[shown[i]]), .position = {x, y}}); x += widths[i] + gap; } } } // The last COUNTDOWN_BEEPS seconds of the ready countdown, or of a timed round, beep as each number ticks over. void Ui::countdown_beep(Game& game) { Match& match = game.sim.match; float left = 0; if (match.state == MATCH_COUNTDOWN) left = match.timer; if (match.state == MATCH_FIGHT && match.time_limit_get() > 0) left = match.time_limit_get() - match.clock; int second = left > 0 && left <= COUNTDOWN_BEEPS ? (int)std::ceil(left) : 0; if (second > 0 && second != countdownBeeped) game.audio.play(SOUND_COUNTDOWN); countdownBeeped = second; } // The countdown while the menu covers the middle (as when choosing items), drawn over the menu's dimming: the round and // seconds left in the margin left of the menu, who's ready in the margin right of it (with the key to ready up until // this screen has), and the tip under the hotbars, below the gamepad hints if they're showing. The round's name drops // to small text if it's too wide for the margin at its usual size. void Ui::countdown_aside_draw(Game& game) { CountdownInfo info = countdown_info_get(game); const char* round = info.round; int ready = info.ready; bool isReady = game.sim.match.isReady[game.local] || game.session.is_watching(game); const char* key = isReady ? nullptr : info.key; float margin = (CANVAS_WIDTH - MENU_WIDTH) / 2; float middle = MENU_TOP + (HEADER_HEIGHT + BODY_HEIGHT) / 2; auto line_draw = [&](const char* line, float centerX, float y, float scale) { float width = game.text.size_get(line, FONT_SMALL, scale).x; game.text.draw( game.renderer, line, {.color = tint, .position = {std::round(centerX - width / 2), std::round(y)}, .scale = {scale, scale}}); return game.text.line_height_get(FONT_SMALL, scale); }; // Each line at the largest size that fits the margin. auto scale_fit = [&](const char* line, float scale) { for (; scale > 1; scale--) if (game.text.size_get(line, FONT_SMALL, scale).x <= margin - ASIDE_PADDING * 2) break; return scale; }; float labelScale = scale_fit(round, ASIDE_LABEL_SCALE); float timeScale = scale_fit(info.time, ROUND_SCALE); float height = game.text.line_height_get(FONT_SMALL, labelScale) + game.text.line_height_get(FONT_SMALL, timeScale); float y = middle - height / 2; y += line_draw(round, margin / 2, y, labelScale); line_draw(info.time, margin / 2, y, timeScale); char text[TOOLTIP_LENGTH]; float rightX = CANVAS_WIDTH - margin / 2; y = middle - (key ? LINE_HEIGHT * 3 : LINE_HEIGHT) / 2; SDL_snprintf(text, sizeof(text), "%d/%d Ready", ready, game.sim.fighterCount); y += line_draw(text, rightX, y, 1); if (key) { SDL_snprintf(text, sizeof(text), "Press %s", key); y += line_draw(text, rightX, y, 1); line_draw("to Ready Up", rightX, y, 1); } tip_draw(game, tip_low_get()); } void Ui::text_center_draw(Game& game, const char* text, float y, float scale, float alpha) { text_center_draw(game, text, y, scale, color_fade(tint, alpha)); } void Ui::text_center_draw(Game& game, const char* text, float y, float scale, Color color) { float width = game.text.size_get(text, FONT_SMALL, scale).x; game.text.draw( game.renderer, text, {.color = color, .position = {std::round((CANVAS_WIDTH - width) / 2), std::round(y)}, .scale = {scale, scale}}); } // Top right: during a match, the local fighter's health as a number beside a bar, then, smaller, frames drawn per // second (counted over each second), damage per second and the local fighter's speed in units per second. void Ui::status_draw(Game& game) { char text[TOOLTIP_LENGTH]; float right = CANVAS_WIDTH - HOTBAR_MARGIN; float left = right; float y = HOTBAR_MARGIN; float rowHeight = game.text.line_height_get(FONT_SMALL, DPS_SCALE); game.renderer.layer_begin(); if (game.sim.match.state != MATCH_PRACTICE && !game.sim.match.is_racing()) { Fighter& fighter = game.local_get(); float maxHealth = game.sim.match.health_get(); Rect bar = {right - STATUS_BAR_WIDTH, std::round(y + (rowHeight - STATUS_BAR_HEIGHT) / 2), STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT}; bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / maxHealth, tint); SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health))); float width = game.text.size_get(text, FONT_SMALL, DPS_SCALE).x; left = std::round(bar.x - STATUS_GAP - width); game.text.draw(game.renderer, text, {.color = tint, .position = {left, y}, .scale = {DPS_SCALE, DPS_SCALE}}); y += rowHeight; } // The meters each show only if they're on in the settings. auto meter_draw = [&](const char* label) { float x = std::round(right - game.text.size_get(label).x); left = std::min(left, x); game.text.draw(game.renderer, label, {.color = tint, .position = {x, y}}); y += game.text.line_height_get(); }; Uint64 now = SDL_GetTicks(); fpsFrames++; if (now - fpsSince >= SDL_MS_PER_SECOND) { fps = fpsFrames * (float)SDL_MS_PER_SECOND / (now - fpsSince); fpsSince = now; fpsFrames = 0; } if (game.settings.isFpsShown) { SDL_snprintf(text, sizeof(text), "%d FPS", (int)std::round(fps)); meter_draw(text); } if (game.settings.isDpsShown) { SDL_snprintf(text, sizeof(text), "%.1f DPS", game.effects.dps_get()); meter_draw(text); } if (game.settings.isSpeedShown) { SDL_snprintf(text, sizeof(text), "%d u/s", (int)vec2_length(game.local_get().body.velocity)); meter_draw(text); } statusRect = {left, HOTBAR_MARGIN, right - left, y - HOTBAR_MARGIN}; game.renderer.layer_end(hover_alpha_get(statusHover)); } // The weapon bar (lit on the selected slot) and the trinket bar (lit where filled). The menu decides where // they sit, and how large. Hovering a filled slot outside the menu shows its tooltip. void Ui::hotbar_draw(Game& game) { Vec2 mouse = game.input.mouse; Fighter& fighter = game.local_get(); Slot hovered; for (int i = 0; i < fighter.weaponSlots + fighter.trinketSlots; i++) { ItemKind kind = i >= fighter.weaponSlots ? ITEM_TRINKET : ITEM_WEAPON; int index = kind == ITEM_TRINKET ? i - fighter.weaponSlots : i; int count = fighter.slots_get(kind); int item = fighter.loadout_get(kind)[index]; Slot slot = { .rect = game.menu.slot_rect(kind, index, count), .size = game.menu.slot_size_get(count), .item = item, .level = fighter.slot_levels_get(kind)[index], .label = index + 1, .isHoverable = true, .isLit = kind == ITEM_TRINKET ? item >= 0 : index == fighter.slot, .kind = kind, .isTarget = game.menu.target_get(game, kind) == index, }; // In the HUD, slots are see-through until the mouse comes over them. Under the menu they're solid. float hover = slotHovers[kind][index]; game.renderer.layer_begin(); slot_draw(game, slot); game.renderer.layer_end(game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover); if (item >= 0 && is_point_in_rect(mouse, slot.rect)) hovered = slot; } if (hovered.item < 0 || game.menu.isOpen) return; Tooltip tooltip = tooltip_build(game, hovered.kind, hovered.item, hovered.level); tooltip.slot = hovered.label - 1; tooltip_draw(game, tooltip); } // A bordered box with the item's icon, a stack of chevrons in the corner for its level, and an optional number (left // off tiny boxes). Only full-size boxes have room for the hover border. void Ui::slot_draw(Game& game, Slot slot) { Renderer& renderer = game.renderer; bool isHovered = slot.isHoverable && !isFocusShown && is_point_in_rect(game.input.mouse, slot.rect); Color color = color_fade(slot.isLit || isHovered || slot.isTarget ? tint : shade, slot.alpha); panel_draw(renderer, slot.rect, color, -1, SLOT_STROKES[slot.size]); if (slot.isTarget) renderer.rect_draw({slot.rect.x + TARGET_INSET, slot.rect.y + slot.rect.h + TARGET_GAP, slot.rect.w - TARGET_INSET * 2, TARGET_HEIGHT}, color); if (isHovered && slot.size == ICON_FULL) { Rect rect = slot.rect; float inset = PANEL_BORDER + HOVER_GAP; chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2}, color); inset += HOVER_BORDER; chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2}, color_fade(BLACK, slot.alpha)); } if (slot.item >= 0) { icon_draw(game, slot.kind, slot.item, {.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}}, slot.size); float inset = CHEVRON_INSETS[slot.size]; Vec2 corner = {slot.rect.x + slot.rect.w - inset, slot.rect.y + slot.rect.h - inset}; for (int i = 0; i <= slot.level; i++) game.sprites.ui.draw(renderer, CHEVRONS[slot.size], 0, {.color = color, .position = {corner.x, corner.y - i * CHEVRON_STEPS[slot.size]}}); } if (slot.label <= 0 || slot.size == ICON_TINY) return; char label[] = {char('0' + slot.label), 0}; Vec2 offset = LABEL_OFFSETS[slot.size]; game.text.draw(renderer, label, {.color = color, .position = {slot.rect.x + offset.x, slot.rect.y + offset.y}}, LABEL_FONTS[slot.size]); } // A number for a stats table: whole when it's whole, otherwise to two decimal places at most. static void number_format(char* text, int size, float value) { SDL_snprintf(text, size, "%g", std::round(value * 100) / 100); } // The stats table's last row, how far a weapon pushes its fighter. constexpr int LUNGE_ROW = 8; // What a summary row shows for an attack, or "" where it doesn't apply. static void stat_format(char* text, int size, int row, const AttackSummary& summary, const AttackDef& attack, AttackVariant variant) { char number[STAT_LENGTH]; text[0] = '\0'; switch (row) { case 0: { if (summary.pieces <= 1) { SDL_strlcpy(text, attack.label, size); break; } int length = SDL_strlen(attack.label); bool isSibilant = length > 0 && (SDL_strchr("sx", attack.label[length - 1]) || (length > 1 && attack.label[length - 1] == 'h' && SDL_strchr("cs", attack.label[length - 2]))); SDL_snprintf(text, size, "%d %s%s", summary.pieces, attack.label, isSibilant ? "es" : "s"); break; } case 1: if (summary.isDamaging) { number_format(number, sizeof(number), summary.damage); SDL_snprintf(text, size, summary.pieces > 1 ? "%s each" : "%s", number); } break; case 2: number_format(number, sizeof(number), summary.every); SDL_snprintf(text, size, variant == VARIANT_CHARGED ? "%ss charge" : "%ss", number); break; case 3: if (std::isinf(summary.range)) SDL_strlcpy(text, "endless", size); else if (summary.range > 0) SDL_snprintf(text, size, "%gu", std::round(summary.range)); break; case 4: if (summary.area > 0) SDL_snprintf(text, size, "%gu", std::round(summary.area)); break; case 5: if (summary.speed > 0) SDL_snprintf(text, size, "%gu/s", std::round(summary.speed)); break; case 6: if (summary.out > 0) SDL_snprintf(text, size, "%d", summary.out); break; case 7: if (summary.spread > 0) { number_format(number, sizeof(number), summary.spread); SDL_snprintf(text, size, "%s°", number); } break; case LUNGE_ROW: if (summary.lunge != 0) SDL_snprintf(text, size, "%g", std::round(std::abs(summary.lunge))); break; } } // Fills a weapon tooltip's stats table: a row per stat the weapon has (fired, damage, how often, range, blast, speed, // how many out at once, spread, lunge), always in that order, for its attack and its charged attack as this fighter // would fire them. A weapon that only pushes its fighter back calls its lunge Recoil. Values its trinkets change are // marked; with a preview, ones the next level changes add "→ next". void Ui::stats_table_build(Fighter& fighter, int weapon, int level, TooltipMode mode, Tooltip& tooltip) { bool isPreview = mode == TOOLTIP_NEXT_LEVEL; constexpr const char* LABELS[] = {"Fires", "Damage", "Every", "Range", "Blast", "Speed", "Out at once", "Spread", "Lunge"}; const WeaponDef& def = WEAPON_DEFS[weapon]; AttackSummary summaries[2] = {attack_summary_get(fighter, weapon, level, VARIANT_NORMAL), attack_summary_get(fighter, weapon, level, VARIANT_CHARGED)}; AttackSummary nexts[2] = {}; for (int column = 0; column < 2 && isPreview; column++) nexts[column] = attack_summary_get(fighter, weapon, level + 1, (AttackVariant)column); bool isRecoil = summaries[0].lunge <= 0 && summaries[1].lunge <= 0; tooltip.rowCount = 0; for (int row = 0; row < (int)std::size(LABELS); row++) { StatRow& stat = tooltip.rows[tooltip.rowCount]; stat = {.label = row == LUNGE_ROW && isRecoil ? "Recoil" : LABELS[row]}; bool isShown = false; for (int column = 0; column < 2; column++) { AttackVariant variant = (AttackVariant)column; const AttackDef& attack = variant == VARIANT_CHARGED ? def.charged : def.normal; AttackSummary& summary = summaries[column]; char* value = stat.values[column]; stat_format(value, STAT_LENGTH, row, summary, attack, variant); bool changes[] = {summary.isPiecesChanged, summary.isDamageChanged, summary.isEveryChanged, summary.isRangeChanged, summary.isAreaChanged, summary.isSpeedChanged, false, summary.isSpreadChanged, summary.isLungeChanged}; stat.isChanged[column] = changes[row]; if (isPreview && *value) { char next[STAT_LENGTH]; stat_format(next, sizeof(next), row, nexts[column], attack, variant); if (SDL_strcmp(next, value) != 0) SDL_snprintf(value + SDL_strlen(value), STAT_LENGTH - SDL_strlen(value), " → %s", next); } isShown |= *value != '\0'; } tooltip.rowCount += isShown; } } // How many value columns a stats table has (a weapon's attack and charged, a trinket's one), and how many header lines // (a weapon's column names, which a trinket goes without). static int table_columns_get(Tooltip& tooltip) { return tooltip.kind == ITEM_WEAPON ? 2 : 1; } static int table_header_get(Tooltip& tooltip) { return tooltip.kind == ITEM_WEAPON ? 1 : 0; } // The stats table's column positions (label, attack, charged) from the left, and its size, in a font. static Vec2 table_layout(Game& game, Tooltip& tooltip, Font font, float* columns) { Text& text = game.text; int count = table_columns_get(tooltip); float widths[3] = {}; for (int column = 0; column < count && tooltip.kind == ITEM_WEAPON; column++) widths[column + 1] = text.size_get(TABLE_HEADERS[column], font).x; for (int i = 0; i < tooltip.rowCount; i++) { StatRow& row = tooltip.rows[i]; widths[0] = std::max(widths[0], text.size_get(row.label, font).x); for (int column = 0; column < count; column++) widths[column + 1] = std::max(widths[column + 1], text.size_get(row.values[column], font).x); } columns[0] = 0; columns[1] = widths[0] + TABLE_GAP; columns[2] = columns[1] + widths[1] + TABLE_GAP; float lineHeight = text.line_height_get(font); int lines = tooltip.rowCount + table_header_get(tooltip); return tooltip.rowCount > 0 ? Vec2{columns[count] + widths[count], lines * lineHeight + TABLE_SPACE} : Vec2{}; } // One line of the stats table at y: the header for row -1, otherwise a row, labels and plain values gray and values // the trinkets change in the tint. void Ui::table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, const float* columns, Font font, float alpha) { Text& text = game.text; if (row < 0) { for (int column = 0; column < 2; column++) text.draw(game.renderer, TABLE_HEADERS[column], {.color = color_fade(tint, alpha), .position = {corner.x + columns[column + 1], corner.y}}, font); return; } StatRow& stat = tooltip.rows[row]; text.draw(game.renderer, stat.label, {.color = color_fade(shade, alpha), .position = corner}, font); for (int column = 0; column < table_columns_get(tooltip); column++) text.draw(game.renderer, stat.values[column], {.color = color_fade(stat.isChanged[column] ? tint : shade, alpha), .position = {corner.x + columns[column + 1], corner.y}}, font); } // Name, numbers at a level and description of a weapon or trinket, previewing the next level's where asked (the // catalog). Trinkets say how many copies are equipped, and when they stack. Tooltip Ui::tooltip_build(Game& game, ItemKind kind, int item, int level, TooltipMode mode) { // The last level has no next to show. mode = level < LEVEL_COUNT - 1 ? mode : TOOLTIP_PLAIN; bool isPreview = mode == TOOLTIP_NEXT_LEVEL; Tooltip tooltip; tooltip.kind = kind; tooltip.item = item; Fighter& fighter = game.local_get(); int* loadout = fighter.loadout_get(kind); int slots = fighter.slots_get(kind); tooltip.slot = std::find(loadout, loadout + slots, item) - loadout; if (tooltip.slot >= slots) tooltip.slot = -1; if (kind) { const TrinketDef& def = TRINKET_DEFS[item]; int copies = fighter.trinket_count(item); char equipped[TOOLTIP_LENGTH]; if (copies > 1) SDL_snprintf(equipped, sizeof(equipped), "%d Copies Equipped", copies); else SDL_strlcpy(equipped, copies > 0 ? "Equipped" : "Not Equipped", sizeof(equipped)); tooltip.name = def.name; // Previewed (in the catalog), the stats show what the next level changes after an arrow, as weapons' do. if (isPreview) SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d → Level %d)\n%s", level + 1, level + 2, equipped); else SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)\n%s", level + 1, equipped); trinket_table_build(item, level, mode, tooltip); SDL_snprintf(tooltip.description, sizeof(tooltip.description), "\n%s", def.blurb); if (*def.flavor) SDL_snprintf(tooltip.flavor, sizeof(tooltip.flavor), "\n\"%s\"", def.flavor); return tooltip; } const WeaponDef& def = WEAPON_DEFS[item]; tooltip.name = def.name; // Previewed (in the catalog), values that change at the next level show it after an arrow. SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), isPreview ? "(Level %d → Level %d)" : "(Level %d)", level + 1, level + 2); stats_table_build(fighter, item, level, mode, tooltip); SDL_snprintf(tooltip.description, sizeof(tooltip.description), "\n%s", def.blurb); if (*def.flavor) SDL_snprintf(tooltip.flavor, sizeof(tooltip.flavor), "\n\"%s\"", def.flavor); return tooltip; } Vec2 Ui::tooltip_size(Game& game, Tooltip& tooltip, Font font) { Text& text = game.text; Vec2 name = text.size_get(tooltip.name, font); Vec2 stats = *tooltip.stats ? text.size_get(tooltip.stats, font) : Vec2{}; Vec2 description = *tooltip.description ? text.size_get(tooltip.description, font) : Vec2{}; Vec2 flavor = *tooltip.flavor ? text.size_get(tooltip.flavor, font) : Vec2{}; float columns[3]; Vec2 table = table_layout(game, tooltip, font, columns); return {std::max({name.x, stats.x, table.x, description.x, flavor.x}), name.y + stats.y + table.y + description.y + flavor.y}; } // Stacks the name (in the tooltip's own color, if it has one), gray stats, description and any gray flavor quote from // draw.position down, faded by draw.color's alpha. void Ui::tooltip_text_draw(Game& game, Tooltip& tooltip, Draw draw, Font font) { Text& text = game.text; float alpha = draw.color.a; Vec2 cursor = draw.position; Color title = tooltip.border.a > 0 ? tooltip.border : tint; text.draw(game.renderer, tooltip.name, {.color = color_fade(title, alpha), .position = cursor}, font); cursor.y += text.size_get(tooltip.name, font).y; if (*tooltip.stats) text.draw(game.renderer, tooltip.stats, {.color = color_fade(shade, alpha), .position = cursor}, font); cursor.y += *tooltip.stats ? text.size_get(tooltip.stats, font).y : 0; float columns[3]; Vec2 table = table_layout(game, tooltip, font, columns); int header = table_header_get(tooltip); for (int row = -header; row < tooltip.rowCount && table.y > 0; row++) table_row_draw(game, tooltip, row, {cursor.x, cursor.y + TABLE_SPACE + (row + header) * text.line_height_get(font)}, columns, font, alpha); cursor.y += table.y; if (*tooltip.description) text.draw(game.renderer, tooltip.description, {.color = color_fade(tint, alpha), .position = cursor}, font); cursor.y += *tooltip.description ? text.size_get(tooltip.description, font).y : 0; if (*tooltip.flavor) text.draw(game.renderer, tooltip.flavor, {.color = color_fade(shade, alpha), .position = cursor}, font); } // The tooltip's text in a scrolling page, a row per line, so it gains a scroll bar whenever it runs taller than the // area. void Ui::tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip) { // The item's icon sits top right, beside its name and level, scrolling with the page (left of its scroll bar). bool isScrolling = page.height > area.h; page_begin(game, page, area); float iconY = area.y - page.scroll; if (tooltip.item >= 0 && iconY >= area.y) tooltip_icon_draw( game, tooltip, {.color = tint, .position = {area.x + area.w - (isScrolling ? PAGE_SCROLL_WIDTH + TOOLTIP_PADDING : 0) - ICON_SIZE / 2, iconY + ICON_SIZE / 2}}); auto lines = [&](const char* text, Color color) { for (const char* start = text;;) { const char* end = SDL_strchr(start, '\n'); int length = end ? (int)(end - start) : (int)SDL_strlen(start); float y = 0; if (row_get(page, LINE_HEIGHT, y)) { char line[TOOLTIP_LENGTH]; SDL_strlcpy(line, start, std::min(length + 1, (int)sizeof(line))); game.text.draw(game.renderer, line, {.color = color, .position = {area.x, y}}); } if (!end) break; start = end + 1; } }; lines(tooltip.name, tint); lines(tooltip.stats, shade); // The table drops to the tiny font when it's too wide for the page. float columns[3]; Font font = table_layout(game, tooltip, FONT_SMALL, columns).x <= area.w ? FONT_SMALL : FONT_TINY; table_layout(game, tooltip, font, columns); float y = 0; if (tooltip.rowCount > 0) row_get(page, TABLE_SPACE, y); for (int row = -table_header_get(tooltip); row < tooltip.rowCount && tooltip.rowCount > 0; row++) if (row_get(page, game.text.line_height_get(font), y)) table_row_draw(game, tooltip, row, {area.x, y}, columns, font, 1); lines(tooltip.description, tint); if (*tooltip.flavor) lines(tooltip.flavor, shade); page_end(game, page); } // Places the tooltip in a panel beside the cursor, kept on screen. void Ui::tooltip_draw(Game& game, Tooltip& tooltip) { Vec2 mouse = game.input.mouse; Vec2 size = tooltip_size(game, tooltip); // The icon sits in the top-right corner, beside the first lines. bool isIcon = tooltip.item >= 0 || tooltip.fashion > 0; if (isIcon) size = {std::max(size.x, beside_icon_get(game, tooltip, FONT_SMALL) + TOOLTIP_PADDING + ICON_SIZE), std::max(size.y, ICON_SIZE)}; Rect panel = {mouse.x + TOOLTIP_OFFSET, mouse.y + TOOLTIP_OFFSET, size.x + TOOLTIP_PADDING * 2, size.y + TOOLTIP_PADDING * 2}; panel.x = std::round(std::min(panel.x, CANVAS_WIDTH - panel.w)); panel.y = std::round(std::min(panel.y, CANVAS_HEIGHT - panel.h)); panel_draw(game.renderer, panel, tooltip.border.a > 0 ? tooltip.border : tint); tooltip_text_draw(game, tooltip, {.position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}}); if (isIcon) tooltip_icon_draw(game, tooltip, {.color = tint, .position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2, panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}}); } // A tooltip's item icon (or fashion item's art), centerd on the placement, with the hotbar slot it's in numbered over // its top left corner as on the hotbar. void Ui::tooltip_icon_draw(Game& game, Tooltip& tooltip, Draw placement) { // A fashion item's art sits centerd in its cell, which is centerd on the placement. float half = FASHION_CELL / 2; if (tooltip.fashion > 0) game.sprites.fashion_icon_draw( game.renderer, tooltip.fashion, {placement.position.x - half, placement.position.y - half, FASHION_CELL, FASHION_CELL}, placement.color); else icon_draw(game, tooltip.kind, tooltip.item, placement); if (tooltip.slot < 0) return; char label[] = {char('1' + tooltip.slot), 0}; Vec2 corner = placement.position - Vec2{ICON_SIZE / 2, ICON_SIZE / 2} + ICON_LABEL_OFFSET; game.text.draw(game.renderer, label, {.color = placement.color, .position = corner}); } // Black box with a cut-cornered edge stroke thick in the given color (its corners cut as deep). The fill fades with // the border unless given an alpha of its own. void Ui::panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha, float stroke) { chamfer_draw(renderer, rect, border, stroke); chamfer_draw(renderer, {rect.x + stroke, rect.y + stroke, rect.w - stroke * 2, rect.h - stroke * 2}, color_fade(BLACK, fillAlpha >= 0 ? fillAlpha : border.a), stroke); } // Fills a rect with its corners cut diagonally, cut pixels deep, one pixel per step. void Ui::chamfer_draw(Renderer& renderer, Rect rect, Color color, float cut) { for (int step = 0; step < cut; step++) { float inset = cut - step; renderer.rect_draw({rect.x + inset, rect.y + step, rect.w - inset * 2, 1}, color); renderer.rect_draw({rect.x + inset, rect.y + rect.h - 1 - step, rect.w - inset * 2, 1}, color); } renderer.rect_draw({rect.x, rect.y + cut, rect.w, rect.h - cut * 2}, color); } void Ui::widgets_begin(float alpha, bool isLive) { widgetAlpha = alpha; isWidgetLive = isLive; isWidgetEnabled = true; } // Disabled widgets draw dimmed and ignore the mouse until enabled again. void Ui::widgets_enable(bool isEnabled) { isWidgetEnabled = isEnabled; } // Under the mouse, while focus isn't showing (so only one thing lights at a time). bool Ui::is_hovered(Game& game, Rect box) { return isWidgetLive && isWidgetEnabled && !isFocusShown && is_point_in_rect(game.input.mouse, box); } // Gray text at a widget row's height, for labels and readouts. void Ui::label_draw(Game& game, const char* text, Vec2 position) { game.text.draw(game.renderer, text, {.color = widget_color(false), .position = {position.x, position.y + TEXT_INSET}}); } // Notes what the option in box does, to show (see hint_draw) while the mouse is over it, even grayed out. void Ui::hint_set(Game& game, Rect box, const char* text) { if (isWidgetLive && !isFocusShown && is_point_in_rect(game.input.mouse, box)) hint = text; } // The hovered option's hint, if any, as a plain tooltip, cleared for the next frame. void Ui::hint_draw(Game& game) { if (!hint) return; Tooltip tooltip; tooltip.name = hint; tooltip_draw(game, tooltip); hint = nullptr; } // A link to a page: lit, underlined under the mouse (or focus), and clicking it opens the page in the browser (a new // tab, in the browser's build). void Ui::link_draw(Game& game, Vec2 position, const char* label, const char* url) { Vec2 size = game.text.size_get(label); Vec2 corner = {position.x, position.y + TEXT_INSET}; Rect box = {position.x, position.y, size.x, size.y}; bool isFocused = focus_add(url, 0, box); bool isMouse = is_hovered(game, box); if ((isMouse && game.input.isClicked) || (isFocused && isConfirmed)) { game.audio.play(SOUND_SELECT); SDL_OpenURL(url); } game.text.draw(game.renderer, label, {.color = widget_color(true), .position = corner}); if (isMouse || isFocused) game.renderer.rect_draw({corner.x, corner.y + size.y, size.x, 1}, widget_color(true)); } // A box that fills in while on, with its label beside it. Clicking either flips it. bool Ui::toggle_draw(Game& game, Vec2 position, const char* label, bool& value) { Rect box = {position.x, position.y, WIDGET_HEIGHT, WIDGET_HEIGHT}; Vec2 size = game.text.size_get(label); Rect area = {box.x, box.y, box.w + WIDGET_GAP + size.x, box.h}; bool isFocused = focus_add(&value, 0, area); bool isMouse = is_hovered(game, area); bool isHovered = isMouse || isFocused; bool isChanged = (isMouse && game.input.isClicked) || (isFocused && isConfirmed); if (isChanged) { value = !value; game.audio.play(SOUND_SELECT); } panel_draw(game.renderer, box, widget_color(isHovered)); if (value) game.renderer.rect_draw( {box.x + CHECK_INSET, box.y + CHECK_INSET, box.w - CHECK_INSET * 2, box.h - CHECK_INSET * 2}, widget_color(true)); game.text.draw( game.renderer, label, {.color = widget_color(isHovered || value), .position = {box.x + box.w + WIDGET_GAP, box.y + TEXT_INSET}}); return isChanged; } // A toggle that's one of a set: on while value is its option, and clicking it picks that option. bool Ui::radio_draw(Game& game, Vec2 position, const char* label, int& value, int option) { Rect box = {position.x, position.y, WIDGET_HEIGHT, WIDGET_HEIGHT}; Vec2 size = game.text.size_get(label); Rect area = {box.x, box.y, box.w + WIDGET_GAP + size.x, box.h}; bool isFocused = focus_add(&value, option, area); bool isMouse = is_hovered(game, area); bool isHovered = isMouse || isFocused; bool isChanged = ((isMouse && game.input.isClicked) || (isFocused && isConfirmed)) && value != option; if (isChanged) { value = option; game.audio.play(SOUND_SELECT); } bool isOn = value == option; panel_draw(game.renderer, box, widget_color(isHovered)); if (isOn) panel_draw(game.renderer, {box.x + RADIO_INSET, box.y + RADIO_INSET, box.w - RADIO_INSET * 2, box.h - RADIO_INSET * 2}, widget_color(true)); game.text.draw( game.renderer, label, {.color = widget_color(isHovered || isOn), .position = {box.x + box.w + WIDGET_GAP, box.y + TEXT_INSET}}); return isChanged; } // A box with its label centerd, lighting up under the mouse like a toggle. Returns whether it was clicked. Its focus // id is its label unless given one (for labels that change or repeat). bool Ui::button_draw(Game& game, Rect box, const char* label, const void* id) { bool isFocused = focus_add(id ? id : label, 0, box); bool isMouse = is_hovered(game, box); bool isHovered = isMouse || isFocused; bool isClicked = (isMouse && game.input.isClicked) || (isFocused && isConfirmed); if (isClicked) game.audio.play(SOUND_SELECT); panel_draw(game.renderer, box, widget_color(isHovered)); Vec2 size = game.text.size_get(label); game.text.draw(game.renderer, label, {.color = widget_color(isHovered), .position = {std::round(box.x + (box.w - size.x) / 2), std::round(box.y + (box.h - size.y) / 2)}}); return isClicked; } // A slot framed like a hotbar slot, lit while value is its option (or it's under the mouse), clicking it picking that // option, like a radio. What it holds is drawn over it. bool Ui::choice_draw(Game& game, Rect box, int& value, int option) { bool isFocused = focus_add(&value, option, box); bool isMouse = is_hovered(game, box); bool isChanged = ((isMouse && game.input.isClicked) || (isFocused && isConfirmed)) && value != option; if (isChanged) { value = option; game.audio.play(SOUND_SELECT); } panel_draw(game.renderer, box, widget_color(value == option || isMouse || isFocused), -1, PANEL_BORDER); return isChanged; } // A color to pick (see choice_draw), filling its slot, more of it once picked. bool Ui::swatch_draw(Game& game, Rect box, Color color, int& value, int option) { bool isChanged = choice_draw(game, box, value, option); float inset = value == option ? SWATCH_INSET_ON : SWATCH_INSET; chamfer_draw(game.renderer, {box.x + inset, box.y + inset, box.w - inset * 2, box.h - inset * 2}, color_fade(color, widgetAlpha)); return isChanged; } // A track filled from the left by value, from 0 to 1. Pressing on it drags the value to the mouse until let go, // snapping to multiples of step. Focused, left and right step it. bool Ui::slider_draw(Game& game, Rect track, float& value, float step) { Input& input = game.input; bool isFocused = focus_add(&value, 0, track, true); bool isHovered = is_hovered(game, track); if (isHovered && input.isClicked) active = &value; bool isDragging = active == &value; float before = value; if (isFocused && adjust != 0) value = std::clamp(std::round(value / step + adjust) * step, 0.0f, 1.0f); isHovered |= isFocused; if (isDragging) { isActiveSeen = true; float inner = track.w - SLIDER_INSET * 2; value = std::clamp((input.mouse.x - track.x - SLIDER_INSET) / inner, 0.0f, 1.0f); value = std::round(value / step) * step; if (!input.is_mouse_down(MOUSE_LEFT) || !isWidgetLive) active = nullptr; } panel_draw(game.renderer, track, widget_color(isHovered || isDragging)); game.renderer.rect_draw({track.x + SLIDER_INSET, track.y + SLIDER_INSET, std::round((track.w - SLIDER_INSET * 2) * value), track.h - SLIDER_INSET * 2}, widget_color(true)); return value != before; } // A text box. Clicking it takes the keyboard and shows a blinking caret. Typing adds characters the font has, up to // size including the terminator, and Enter, Escape or clicking elsewhere lets go. bool Ui::field_draw(Game& game, Rect box, char* text, int size) { Input& input = game.input; bool isFocused = focus_add(text, 0, box); bool isHovered = is_hovered(game, box); bool isTyping = active == text; if (!isTyping && ((isHovered && input.isClicked) || (isFocused && isConfirmed))) { active = text; isTyping = true; SDL_StartTextInput(game.window); } bool isChanged = false; if (isTyping) { isActiveSeen = true; int length = SDL_strlen(text); for (int i = 0; i < input.erases && length > 0; i++) text[--length] = '\0'; for (const char* character = input.typed; *character && length < size - 1; character++) { if ((unsigned char)*character < GLYPH_FIRST || (unsigned char)*character >= GLYPH_TYPED_END) continue; text[length++] = *character; text[length] = '\0'; } isChanged = input.erases > 0 || input.typed[0] != '\0'; if (input.isSubmitted || !isWidgetLive || (input.isClicked && !isHovered)) widget_release(game); } panel_draw(game.renderer, box, widget_color(isHovered || isFocused || isTyping)); Vec2 corner = {box.x + FIELD_INSET, box.y + TEXT_INSET}; game.text.draw(game.renderer, text, {.color = widget_color(true), .position = corner}); bool isCaretShown = (int)(SDL_GetTicks() / (CARET_BLINK * SDL_MS_PER_SECOND)) % 2 == 0; if (isTyping && isCaretShown) { float caretX = corner.x + game.text.size_get(text).x; game.renderer.rect_draw({caretX, box.y + (box.h - CARET_HEIGHT) / 2, 1, CARET_HEIGHT}, widget_color(true)); } return isChanged; } bool Ui::is_active(void* value) { return active == value; } Color Ui::widget_color(bool isLit) { return color_fade(isLit ? tint : shade, widgetAlpha * (isWidgetEnabled ? 1 : DISABLED_ALPHA)); } // A number between a minus and a plus button that step it. Clicking the number types a new one, taken (clamped to the // range) on Enter or clicking elsewhere. Focused, left and right step it. bool Ui::number_draw(Game& game, Rect box, float& value, NumberRange range) { return number_widget(game, box, value, range, &value); } bool Ui::number_draw(Game& game, Rect box, int& value, NumberRange range) { float number = value; bool isChanged = number_widget(game, box, number, range, &value); value = std::round(number); return isChanged; } // The number widget for a value known by id (its own address, which stays the same from frame to frame). bool Ui::number_widget(Game& game, Rect box, float& value, NumberRange range, void* id) { Input& input = game.input; Rect minus = {box.x, box.y, box.h, box.h}; Rect plus = {box.x + box.w - box.h, box.y, box.h, box.h}; Rect middle = {minus.x + minus.w + CHAMFER, box.y, box.w - box.h * 2 - CHAMFER * 2, box.h}; float before = value; bool isTyping = active == id; bool isFocused = focus_add(id, 0, box, true); if (isFocused && adjust != 0 && !isTyping) { value = std::clamp(value + adjust * range.step, range.min, range.max); game.audio.play(SOUND_SELECT); } for (Rect button : {minus, plus}) { bool isHovered = is_hovered(game, button) || (isFocused && adjust == (button.x == minus.x ? -1 : 1)); if (isHovered && input.isClicked && !isTyping) { value = std::clamp(value + (button.x == minus.x ? -range.step : range.step), range.min, range.max); game.audio.play(SOUND_SELECT); } panel_draw(game.renderer, button, widget_color(isHovered)); const char* sign = button.x == minus.x ? "-" : "+"; Vec2 size = game.text.size_get(sign); game.text.draw(game.renderer, sign, {.color = widget_color(isHovered), .position = {std::round(button.x + (button.w - size.x) / 2), button.y + TEXT_INSET}}); } bool isHovered = is_hovered(game, middle); if (!isTyping && isHovered && input.isClicked) { active = id; isTyping = true; numberText[0] = '\0'; SDL_StartTextInput(game.window); } if (isTyping) { isActiveSeen = true; int length = SDL_strlen(numberText); for (int i = 0; i < input.erases && length > 0; i++) numberText[--length] = '\0'; for (const char* character = input.typed; *character && length < (int)sizeof(numberText) - 1; character++) { if ((*character < '0' || *character > '9') && *character != '.') continue; numberText[length++] = *character; numberText[length] = '\0'; } if (input.isSubmitted || !isWidgetLive || (input.isClicked && !isHovered)) { if (numberText[0] != '\0') value = std::clamp((float)SDL_atof(numberText), range.min, range.max); widget_release(game); isTyping = false; } } char text[TOOLTIP_LENGTH]; if (isTyping) SDL_strlcpy(text, numberText, sizeof(text)); else if (value == 0 && range.zero) SDL_strlcpy(text, range.zero, sizeof(text)); else SDL_snprintf(text, sizeof(text), "%g%s", value, range.suffix); panel_draw(game.renderer, middle, widget_color(isHovered || isFocused || isTyping)); Vec2 size = game.text.size_get(text); float textX = std::round(middle.x + (middle.w - size.x) / 2); game.text.draw(game.renderer, text, {.color = widget_color(true), .position = {textX, middle.y + TEXT_INSET}}); bool isCaretShown = (int)(SDL_GetTicks() / (CARET_BLINK * SDL_MS_PER_SECOND)) % 2 == 0; if (isTyping && isCaretShown) game.renderer.rect_draw({textX + size.x, middle.y + (middle.h - CARET_HEIGHT) / 2, 1, CARET_HEIGHT}, widget_color(true)); return value != before; } // A section title centerd in a row, with a line either side of it. void Ui::separator_draw(Game& game, Rect row, const char* label) { Vec2 size = game.text.size_get(label); float textX = std::round(row.x + (row.w - size.x) / 2); float lineY = std::round(row.y + row.h / 2); Color color = widget_color(false); game.renderer.rect_draw({row.x, lineY, textX - SEPARATOR_GAP - row.x, 1}, color); game.renderer.rect_draw({textX + size.x + SEPARATOR_GAP, lineY, row.x + row.w - textX - size.x - SEPARATOR_GAP, 1}, color); game.text.draw(game.renderer, label, {.color = widget_color(true), .position = {textX, row.y + TEXT_INSET}}); } // A separator that folds what's under it away: an arrow (right when folded, down when open) before its label, which // lights under the mouse. Returns whether it was clicked, to flip it. bool Ui::fold_draw(Game& game, Rect row, const char* label, bool isOpen) { bool isFocused = focus_add(label, 0, row); bool isHovered = is_hovered(game, row) || isFocused; bool isClicked = (is_hovered(game, row) && game.input.isClicked) || (isFocused && isConfirmed); if (isClicked) game.audio.play(SOUND_SELECT); Vec2 size = game.text.size_get(label); float width = FOLD_ARROW * 2 + SEPARATOR_GAP + size.x; float startX = std::round(row.x + (row.w - width) / 2); float textX = startX + FOLD_ARROW * 2 + SEPARATOR_GAP; float lineY = std::round(row.y + row.h / 2); Color line = widget_color(isHovered); game.renderer.rect_draw({row.x, lineY, startX - SEPARATOR_GAP - row.x, 1}, line); game.renderer.rect_draw({textX + size.x + SEPARATOR_GAP, lineY, row.x + row.w - textX - size.x - SEPARATOR_GAP, 1}, line); // The arrow, a pixel row (or column) at a time. for (int i = 0; i < FOLD_ARROW; i++) { float length = (FOLD_ARROW - i) * 2 - 1; if (isOpen) game.renderer.rect_draw({startX + i, lineY - FOLD_ARROW / 2 + i, length, 1}, widget_color(true)); else game.renderer.rect_draw({startX + i, lineY - FOLD_ARROW + 1 + i, 1, length}, widget_color(true)); } game.text.draw(game.renderer, label, {.color = widget_color(true), .position = {textX, row.y + TEXT_INSET}}); return isClicked; } // A fighter walking on the spot at the right of an area, in a color and wearing worn, its name over it and a caption // under it (a line each). void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color color, const char* caption, int worn) { // The name, the fighter at its tallest in what it wears, and the caption, stacked and centerd in the column (by // their most, so it holds still as it walks). float center = area.x + area.w - FIGHTER_PREVIEW_WIDTH / 2; Vec2 nameSize = game.text.size_get(name); float body = std::max(game.sprites.player.rise_get(PREVIEW_ANIMATION), game.sprites.fashion_height_get(worn)); float captionHeight = caption && *caption ? game.text.size_get(caption).y : 0; float height = nameSize.y + PREVIEW_NAME_GAP + body * PREVIEW_SCALE + PREVIEW_CAPTION_GAP + captionHeight; float top = std::round(area.y + (area.h - height) / 2); game.text.draw(game.renderer, name, {.color = color, .position = {std::round(center - nameSize.x / 2), top}}); float feet = std::round(top + nameSize.y + PREVIEW_NAME_GAP + body * PREVIEW_SCALE); player_draw(game, {center, feet}, color, PREVIEW_SCALE, worn); float y = feet + PREVIEW_CAPTION_GAP; for (const char* start = caption; start && *start;) { const char* end = SDL_strchr(start, '\n'); char line[CAPTION_LENGTH]; SDL_strlcpy(line, start, std::min(end ? end - start + 1 : sizeof(line), sizeof(line))); Vec2 size = game.text.size_get(line); game.text.draw(game.renderer, line, {.color = shade, .position = {std::round(center - size.x / 2), y}}); y += size.y; start = end ? end + 1 : nullptr; } } // A player walking in place (slowly) from their feet, for previews. void Ui::player_draw(Game& game, Vec2 feet, Color color, float scale, int worn) { Anm2& player = game.sprites.player; Anm2Animation* preview = player.animation_get(PREVIEW_ANIMATION); float frame = preview ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)preview->length) : 0; Pose pose = {.art = &player, .animation = PREVIEW_ANIMATION, .placement = {.color = color, .position = feet, .scale = {scale, scale}}, .frame = frame}; pose.draw(game.renderer); game.sprites.fashion_draw(game.renderer, pose, worn); } // A connection's quality: signal bars (more lit the quicker it is, green to red) and its round trip in milliseconds, a // line tall from its top left corner. Returns how wide it is. Unknown yet, it's the bars alone, unlit. constexpr float PING_BAR_WIDTH = 2; constexpr float PING_BAR_GAP = 1; constexpr int PING_BARS = 4; constexpr float PING_BARS_WIDTH = PING_BARS * (PING_BAR_WIDTH + PING_BAR_GAP) - PING_BAR_GAP; constexpr float PING_BAR_STEP = 2; constexpr float PING_BARS_HEIGHT = PING_BARS * PING_BAR_STEP; constexpr float PING_BARS_DROP = 3; constexpr float PING_UNLIT_SHADE = 0.5f; constexpr float PING_LIMITS[PING_BARS - 1] = {60, 120, 200}; constexpr Color PING_COLORS[PING_BARS] = { {0.9f, 0.25f, 0.2f, 1}, {0.95f, 0.6f, 0.2f, 1}, {0.9f, 0.85f, 0.25f, 1}, {0.35f, 0.85f, 0.35f, 1}}; static std::string ping_text_get(float ping) { return ping < 0 ? std::string() : std::to_string((int)std::round(ping)) + "ms"; } float Ui::ping_width_get(Game& game, float ping) { std::string text = ping_text_get(ping); return PING_BARS_WIDTH + (text.empty() ? 0 : PING_BAR_GAP * 2 + game.text.size_get(text.c_str()).x); } // How many of a connection's bars are lit (none while it's unknown). static int ping_lit_get(float ping) { if (ping < 0) return 0; int lit = PING_BARS; for (float limit : PING_LIMITS) lit -= ping >= limit; return std::max(lit, 1); } float Ui::ping_draw(Game& game, Vec2 corner, float ping, float alpha) { ping_bars_draw(game, {corner.x, corner.y + LINE_HEIGHT - PING_BARS_DROP}, ping, alpha); int lit = ping_lit_get(ping); std::string text = ping_text_get(ping); if (!text.empty()) game.text.draw(game.renderer, text.c_str(), {.color = color_fade(PING_COLORS[lit - 1], alpha), .position = {std::round(corner.x) + PING_BARS_WIDTH + PING_BAR_GAP * 2, corner.y}}); return ping_width_get(game, ping); } // A connection's signal bars alone, standing on corner (their bottom left). void Ui::ping_bars_draw(Game& game, Vec2 corner, float ping, float alpha) { int lit = ping_lit_get(ping); Color color = lit > 0 ? PING_COLORS[lit - 1] : shade; float x = std::round(corner.x); float bottom = std::round(corner.y); for (int i = 0; i < PING_BARS; i++) { float height = PING_BAR_STEP * (i + 1); game.renderer.rect_draw({x, bottom - height, PING_BAR_WIDTH, height}, color_fade(i < lit ? color : color_shade(shade, PING_UNLIT_SHADE), alpha)); x += PING_BAR_WIDTH + PING_BAR_GAP; } } // What a game's rules make for, in a few lines under a title with its mode's icon: what it's about, how many play, the // slots, how items are handed out, and how it's won. The "game tooltip". static std::vector game_summary_lines(const Rules& rules) { std::vector lines; bool isRace = rules.mode == MODE_RACE; lines.push_back(isRace ? "- First to the finish wins!" : "- Fight to the death!"); lines.push_back("- " + std::to_string(rules.players) + " players"); auto count = [](int n, const char* one) { return std::to_string(n) + " " + one + (n == 1 ? "" : "s"); }; lines.push_back("- " + count(rules.weapons.slots, "weapon") + ", " + count(rules.trinkets.slots, "trinket")); auto when = [](const ItemRules& items) { if (items.timing == PICK_EVERY_ROUND) return std::string("every round"); if (items.timing == PICK_PERIOD) return "every " + std::to_string(items.period) + " rounds"; return std::string("on the first round"); }; auto handed = [&](const ItemRules& items, const char* one) { std::string what = std::string(one) + (items.slots == 1 ? "" : "s"); return (items.isChoice ? "Choose " + what : "Random " + what) + " " + when(items); }; const ItemRules& weapons = rules.weapons; const ItemRules& trinkets = rules.trinkets; if (weapons.isChoice == trinkets.isChoice && weapons.timing == trinkets.timing && (weapons.timing != PICK_PERIOD || weapons.period == trinkets.period)) lines.push_back(std::string("- ") + (weapons.isChoice ? "Choose" : "Random") + " weapon" + (weapons.slots == 1 ? "" : "s") + " and trinket" + (trinkets.slots == 1 ? "" : "s") + " " + when(weapons)); else { lines.push_back("- " + handed(weapons, "weapon")); lines.push_back("- " + handed(trinkets, "trinket")); } if (isRace) lines.push_back(rules.timer > 0 ? "- " + std::to_string(rules.timer) + " minute time limit" : "- No time limit"); else lines.push_back("- Best of " + std::to_string(rules.bestOf)); return lines; } Vec2 Ui::game_summary_size(Game& game, const Rules& rules) { float width = ICON_SIZE + SUMMARY_ICON_GAP + game.text.size_get("Game: Deathmatch").x; std::vector lines = game_summary_lines(rules); for (std::string& line : lines) width = std::max(width, game.text.size_get(line.c_str()).x); return {width, ICON_SIZE + lines.size() * LINE_HEIGHT}; } // The game's summary from its top left corner, returning how tall it is. float Ui::game_summary_draw(Game& game, const Rules& rules, Vec2 corner, float alpha) { bool isRace = rules.mode == MODE_RACE; const char* mode = isRace ? "Race" : "Deathmatch"; Color color = color_fade(tint, alpha); game.sprites.modes.draw(game.renderer, mode, 0, {.color = color, .position = {corner.x + ICON_SIZE / 2, corner.y + ICON_SIZE / 2}}); std::string title = std::string("Game: ") + mode; game.text.draw( game.renderer, title.c_str(), {.color = color, .position = {corner.x + ICON_SIZE + SUMMARY_ICON_GAP, std::round(corner.y + (ICON_SIZE - LINE_HEIGHT) / 2)}}); float y = corner.y + ICON_SIZE; for (std::string& line : game_summary_lines(rules)) { game.text.draw(game.renderer, line.c_str(), {.color = color_fade(shade, alpha), .position = {corner.x, y}}); y += LINE_HEIGHT; } return y - corner.y; } // Starts laying rows into an area, scrolled by the wheel while the mouse is over it, or by moving the focus past its // top or bottom row. void Ui::page_begin(Game& game, Page& page, Rect area) { page.area = area; if (isWidgetLive && is_point_in_rect(game.input.mouse, area)) page.scroll -= game.input.scroll * SCROLL_STEP; float maxScroll = std::max(0.0f, page.height - area.h); float before = std::clamp(page.scroll, 0.0f, maxScroll); page.scroll = std::clamp(page.scroll + scroll_take(area) * SCROLL_STEP, 0.0f, maxScroll); if (page.scroll != before) scroll_mark(); page.cursor = area.y; } // Takes the next row of a page, giving where it sits and whether it's fully inside the area (only then drawn). bool Ui::row_get(Page& page, float height, float& y) { y = page.cursor - page.scroll; page.cursor += height; return y >= page.area.y && y + height <= page.area.y + page.area.h; } // Ends a page, drawing a scroll bar down its right edge when its rows don't all fit. void Ui::page_end(Game& game, Page& page) { page.height = page.cursor - page.area.y; if (page.height <= page.area.h) return; Rect track = {page.area.x + page.area.w - PAGE_SCROLL_WIDTH, page.area.y, PAGE_SCROLL_WIDTH, page.area.h}; float head = track.h * page.area.h / page.height; float headY = track.y + (track.h - head) * page.scroll / (page.height - page.area.h); game.renderer.rect_draw(track, widget_color(false)); game.renderer.rect_draw({track.x, std::round(headY), track.w, std::round(head)}, widget_color(true)); } // Lets go of whatever widget was dragging or typing, giving the keyboard back to the game. void Ui::widget_release(Game& game) { active = nullptr; SDL_StopTextInput(game.window); } // Fades a cooldown bar in while it has cooldown left and out once it hasn't, keeping its last fill while it goes. static void bar_fade(CooldownBar& bar, float left) { if (left > 0) bar.fill = left; else if (bar.fade <= 0) bar.fill = 0; bar.fade = std::clamp(bar.fade + (left > 0 ? TICK / COOLDOWN_FADE_IN : -TICK / COOLDOWN_FADE_OUT), 0.0f, 1.0f); } // Gives every showing bar of one kind (one per slot) a row, in slot order. Rows ease toward their places, so bars // slide rather than jump as others come and go. static void bars_arrange(CooldownBar* bars) { int row = 0; for (int slot = 0; slot < SLOT_MAX; slot++) { CooldownBar& bar = bars[slot]; if (bar.fade <= 0) bar.row = -1; else if (bar.row < 0) bar.row = row++; else bar.row += (row++ - bar.row) * std::min(1.0f, COOLDOWN_SLIDE * TICK); } } // Keeps the HUD's timers and the cooldown bars up to date. void Ui::update(Game& game) { hints_update(game); tips_update(game); spectate_update(game); // The HUD's elements change while the mouse is over them, and change back after. Vec2 mouse = game.input.mouse; auto hover_step = [&](float& hover, bool isHovered) { hover = std::clamp(hover + (isHovered ? 1 : -1) * TICK / HUD_HOVER_FADE, 0.0f, 1.0f); }; Fighter& local = game.local_get(); for (ItemKind kind : ITEM_KINDS) { int count = local.slots_get(kind); for (int slot = 0; slot < SLOT_MAX; slot++) hover_step(slotHovers[kind][slot], slot < count && is_point_in_rect(mouse, game.menu.slot_rect(kind, slot, count))); } RivalLayout rivals = rivals_layout(game); for (int i = 0; i < MAX_FIGHTERS; i++) { int k = std::find(rivals.fighters, rivals.fighters + rivals.count, i) - rivals.fighters; hover_step(rivalHovers[i], k < rivals.count && is_point_in_rect(mouse, rivals.cards[k])); } for (int slot = 0; slot < SLOT_MAX; slot++) { hover_step(weaponBars[slot].hover, weaponBars[slot].fade > 0 && is_point_in_rect(mouse, weaponBars[slot].rect)); hover_step(trinketBars[slot].hover, trinketBars[slot].fade > 0 && is_point_in_rect(mouse, trinketBars[slot].rect)); } hover_step(statusHover, is_point_in_rect(mouse, statusRect)); // Every slot cools on its own bar: weapons whose cooldown is long enough to notice (copies that don't stack share // the first one's), and trinkets with a cooldown of their own (each copy its own, see trinket_cooldown_get). A // bar keeps its item's icon while it fades out after the item goes. Fighter& fighter = game.local_get(); float gloves = fighter.stat_get(STAT_ATTACK_RATE); for (int slot = 0; slot < SLOT_MAX; slot++) { float left = 0; if (slot < fighter.weaponSlots && fighter.weapon_key(slot) == slot) { float total = fighter.level_get(slot).cooldown / gloves; left = total >= COOLDOWN_VISIBLE ? fighter.cooldowns[slot] / total : 0; weaponBars[slot].item = left > 0 ? fighter.weaponLoadout[slot] : weaponBars[slot].item; } bar_fade(weaponBars[slot], left); int trinket = fighter.trinketLoadout[slot]; left = 0; if (slot < fighter.trinketSlots && trinket >= 0) { int copy = std::count(fighter.trinketLoadout, fighter.trinketLoadout + slot, trinket); left = fighter.trinket_cooldown_get(trinket, copy); trinketBars[slot].item = left > 0 ? trinket : trinketBars[slot].item; } bar_fade(trinketBars[slot], left); } bars_arrange(weaponBars); bars_arrange(trinketBars); } // Every item cooling down shows at once, a row each: weapons downward from under the weapon hotbar, trinkets upward // from over the trinket hotbar. A kind's bars are two of its slots long, with its icon just right at its hotbar's // size, and start far enough off the hotbar that the icons (taller than the bars) clear its slots. Each drains with // its cooldown and fades with its bar. void Ui::cooldowns_draw(Game& game) { Fighter& fighter = game.local_get(); for (ItemKind kind : ITEM_KINDS) { IconSize size = game.menu.slot_size_get(fighter.slots_get(kind)); float height = COOLDOWN_BAR_HEIGHTS[size]; float icon = ICON_PIXELS[size]; float width = COOLDOWN_SLOTS * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size]; float spacing = std::max(icon, height) + COOLDOWN_ROW_GAP; float start = HOTBAR_MARGIN + SLOT_SIZES[size] + COOLDOWN_GAP + std::ceil(std::max(0.0f, (icon - height) / 2)); CooldownBar* bars = kind == ITEM_TRINKET ? trinketBars : weaponBars; for (int slot = 0; slot < SLOT_MAX; slot++) { CooldownBar& bar = bars[slot]; if (bar.fade <= 0 || bar.row < 0 || bar.item < 0) continue; float step = std::round(bar.row * spacing); Rect rect = {HOTBAR_MARGIN, kind == ITEM_TRINKET ? CANVAS_HEIGHT - start - height - step : start + step, width, height}; float middle = rect.y + height / 2; bar.rect = {rect.x, middle - spacing / 2, width + COOLDOWN_GAP + icon, spacing}; game.renderer.layer_begin(); bar_draw(game.renderer, rect, bar.fill, tint, SLOT_STROKES[size]); icon_draw(game, kind, bar.item, {.color = tint, .position = {std::round(rect.x + width + COOLDOWN_GAP + icon / 2), middle}}, size); game.renderer.layer_end(bar.fade * hover_alpha_get(bar.hover)); } } } // A weapon's or trinket's HUD icon, centerd on the placement. void Ui::icon_draw(Game& game, ItemKind kind, int item, Draw placement, IconSize size) { char name[TOOLTIP_LENGTH]; SDL_snprintf(name, sizeof(name), "%s%s", item_name_get(kind, item), ICON_SUFFIXES[size]); (kind == ITEM_TRINKET ? game.sprites.trinkets : game.sprites.icons).draw(game.renderer, name, 0, placement); } // An arrow at the screen's edge for every other living fighter out of view, pointing toward it, in its color, with // its name just inside. void Ui::offscreen_draw(Game& game, Vec2 camera) { Vec2 half = {CANVAS_WIDTH / 2.0f, CANVAS_HEIGHT / 2.0f}; for (int i = 0; i < game.sim.fighterCount; i++) { Fighter& fighter = game.sim.fighters[i]; Vec2 offset = fighter.center_get() - camera - half; bool isVisible = std::abs(offset.x) < half.x && std::abs(offset.y) < half.y; if (i == game.local || !fighter.isAlive || isVisible) continue; float reach = std::min((half.x - OFFSCREEN_MARGIN) / std::max(1.0f, std::abs(offset.x)), (half.y - OFFSCREEN_MARGIN) / std::max(1.0f, std::abs(offset.y))); Vec2 edge = half + offset * reach; Color color = game.color_get(i); game.sprites.ui.draw(game.renderer, "Arrow", 0, {.color = color, .position = edge, .scale = {OFFSCREEN_ARROW_SCALE, OFFSCREEN_ARROW_SCALE}, .angle = vec2_angle(offset) + PI / 2}); const char* name = name_get(game, i); Vec2 size = game.text.size_get(name); Vec2 label = edge - vec2_normalize(offset) * (OFFSCREEN_NAME_GAP + size.x / 2) - size * 0.5f; label.x = std::clamp(label.x, 0.0f, CANVAS_WIDTH - size.x); label.y = std::clamp(label.y, 0.0f, CANVAS_HEIGHT - size.y); game.text.draw(game.renderer, name, {.color = color, .position = {std::round(label.x), std::round(label.y)}}); } } // Each of this screen's fighters' crosshairs in its color, filling in and shaking with its weapon's charge and // cooldown, and darkened through the cooldown: at the mouse for the keyboard's (and whenever the menu is open), // otherwise where its stick aims. With none (a bot playing), the mouse's. void Ui::crosshairs_draw(Game& game, Vec2 camera) { Input& input = game.input; bool isAny = false; for (int i = 0; i < game.sim.fighterCount; i++) { if (!game.is_local(i)) continue; isAny = true; bool isMouse = (game.devices[i] == DEVICE_KEYBOARD && !input.devices[DEVICE_KEYBOARD].isAiming) || (i == game.local && game.menu.isOpen); Fighter& fighter = game.sim.fighters[i]; int held = fighter.weapon_key(fighter.slot); Vec2 aim = (isMouse ? input.mouse : fighter.controls.cursor - camera) + game.scene.weapon_shake_get(fighter, held); game.sprites.ui.draw(game.renderer, "Crosshair", 0, {.color = game.scene.cooldown_tint_get(fighter, held, game.color_get(i)), .position = {std::floor(aim.x), std::floor(aim.y)}, .flash = game.scene.charge_fill_get(fighter)}); } if (!isAny) game.sprites.ui.draw(game.renderer, "Crosshair", 0, {.color = tint, .position = {std::floor(input.mouse.x), std::floor(input.mouse.y)}}); } // With several players on this screen in a match, a compact panel each along the bottom in their color: name and // health, then rows of small weapon and trinket slots, the held weapon lit. void Ui::panels_draw(Game& game) { int players[MAX_FIGHTERS]; int count = 0; for (int i = 0; i < game.sim.fighterCount; i++) if (game.is_local(i)) players[count++] = i; float width = (CANVAS_WIDTH - HOTBAR_MARGIN * 2 - LOADOUT_GAP * (count - 1)) / count; float top = CANVAS_HEIGHT - HOTBAR_MARGIN - PLAYER_PANEL_HEIGHT; for (int k = 0; k < count; k++) { int index = players[k]; Fighter& fighter = game.sim.fighters[index]; Color color = game.color_get(index); Color dim = color_shade(color, SECONDARY_SHADE); Rect panel = {std::round(HOTBAR_MARGIN + k * (width + LOADOUT_GAP)), top, std::floor(width), PLAYER_PANEL_HEIGHT}; panel_draw(game.renderer, panel, color); float x = panel.x + PICK_PADDING; float y = panel.y + PICK_PADDING; const char* name = name_get(game, index); game.text.draw(game.renderer, name, {.color = color, .position = {x, y}}); if (!game.sim.match.is_racing()) { float barX = x + game.text.size_get(name).x + PICK_PADDING; Rect bar = {barX, y + 2, panel.x + panel.w - PICK_PADDING - barX, HEALTH_BAR_HEIGHT}; bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / game.sim.match.health_get(), color); } int fit = (panel.w - PICK_PADDING * 2 + MINI_STEP - MINI_SLOT) / MINI_STEP; for (ItemKind kind : ITEM_KINDS) { int slots = std::min(fit, fighter.slots_get(kind)); float rowY = y + LINE_HEIGHT + (int)kind * MINI_STEP; for (int slot = 0; slot < slots; slot++) { int item = fighter.loadout_get(kind)[slot]; bool isLit = kind == ITEM_TRINKET ? item >= 0 : slot == fighter.slot; Rect box = {x + slot * MINI_STEP, rowY, MINI_SLOT, MINI_SLOT}; panel_draw(game.renderer, box, isLit ? color : dim); if (item >= 0) icon_draw(game, kind, item, {.color = isLit ? color : dim, .position = {box.x + box.w / 2, box.y + box.h / 2}, .scale = {MINI_ICON_SCALE, MINI_ICON_SCALE}}); } } } } // Whether this screen is watching others: its only fighter is out of a round still being played (dead in a // deathmatch, or over the line in a race) and someone else is in it. static bool is_spectating(Game& game) { MatchState state = (MatchState)game.sim.match.state; // Watching a queued game from the lobby, it's whoever's still in it. if (game.session.is_watching(game)) { for (int i = 0; i < game.sim.fighterCount; i++) if (game.sim.fighters[i].isAlive) return true; return false; } if ((state != MATCH_FIGHT && state != MATCH_ROUND_OVER) || game.local_count() != 1 || game.local_get().isAlive) return false; for (int i = 0; i < game.sim.fighterCount; i++) if (i != game.local && game.sim.fighters[i].isAlive) return true; return false; } // Picks who to watch while spectating. It starts on whoever took this screen's fighter out, and moves on to whoever // takes the watched one out (or the next one alive). Left and Right step back and forth through the living, as does // Attack (forward). Presses are caught here, against last tick's state, since the tick's own edges are gone by now. void Ui::spectate_update(Game& game) { Input& input = game.input; bool isWatching = game.session.is_watching(game); int device = isWatching ? DEVICE_KEYBOARD : game.devices[game.local]; bool held[3] = {}; if (device != DEVICE_NONE) for (int i = 0; i < 3; i++) held[i] = input.is_control_down(device, SPECTATE_CONTROLS[i]); bool isPressed[3]; for (int i = 0; i < 3; i++) { isPressed[i] = held[i] && !spectateHeld[i]; spectateHeld[i] = held[i]; } if (!is_spectating(game)) { spectated = -1; return; } auto is_watchable = [&](int fighter) { return fighter >= 0 && fighter < game.sim.fighterCount && (fighter != game.local || isWatching) && game.sim.fighters[fighter].isAlive; }; auto next_get = [&](int from, int step) { for (int k = 1; k <= game.sim.fighterCount; k++) { int fighter = ((from + step * k) % game.sim.fighterCount + game.sim.fighterCount) % game.sim.fighterCount; if (is_watchable(fighter)) return fighter; } return from; }; if (!is_watchable(spectated)) { int taker = spectated >= 0 ? game.sim.fighters[spectated].launcher : game.local_get().launcher; spectated = is_watchable(taker) ? taker : next_get(spectated >= 0 ? spectated : game.local, 1); return; } int step = (isPressed[1] || isPressed[2]) - isPressed[0]; if (step != 0 && !game.menu.isOpen) { spectated = next_get(spectated, step); game.audio.play(SOUND_SELECT); } } // While spectating, who's being watched, in their color, near the bottom, with the controls that switch. void Ui::spectate_draw(Game& game) { if (spectated < 0) return; Input& input = game.input; char text[TOOLTIP_LENGTH]; SDL_snprintf(text, sizeof(text), "Spectating %s", name_get(game, spectated)); float y = CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT * 2 - SPECTATE_GAP; text_center_draw(game, text, y, 1, game.color_get(spectated, TINT_PLAIN)); char keys[2][TOOLTIP_LENGTH]; for (int i = 0; i < 2; i++) input.control_name_get(i == 0 ? CONTROL_LEFT : CONTROL_RIGHT, keys[i], sizeof(keys[i])); SDL_snprintf(text, sizeof(text), "%s / %s: Switch", keys[0], keys[1]); text_center_draw(game, text, y + LINE_HEIGHT, 1, shade); } // A fighter's connection for its window, online (PING_NONE otherwise). static float rival_ping_get(Game& game, int fighter) { bool isShown = game.session.isActive && game.settings.isPingShown; return isShown ? game.session.ping_get(game, game.sim.match.owners[fighter]) : PING_NONE; } // Lays out the other fighters' windows in the HUD's right column, from the bottom up to the readouts above, in no more // than two rows: as many to a row as fit across (the rows as even as they go). A window is as wide as its icons, name // or health (with its connection's bars, online). Without icons, the health number can go to make room. Full-size // icons stack no more than two rows deep across the windows. When they don't all fit, the icons shrink to small, then // the font to tiny, then the icons to tiny, then the icons are left out. Each kind of icon wraps onto more rows past // three across, and tiny icons wrap further if they still don't fit. If even names and health alone don't fit, the // windows split the column evenly and the names are cut short. RivalLayout Ui::rivals_layout(Game& game) { RivalLayout layout; int slots[2] = {}; for (int i = 0; i < game.sim.fighterCount; i++) { if (i == game.local && !game.session.is_watching(game)) continue; layout.fighters[layout.count++] = i; slots[0] = std::max(slots[0], game.sim.fighters[i].weaponSlots); slots[1] = std::max(slots[1], game.sim.fighters[i].trinketSlots); } if (layout.count == 0) return layout; float top = statusRect.h > 0 ? statusRect.y + statusRect.h + RIVAL_GAP : HOTBAR_MARGIN; float room = CANVAS_HEIGHT - HOTBAR_MARGIN - top; float inset = PANEL_BORDER + RIVAL_PADDING; bool isRace = game.sim.match.is_racing(); char health[TOOLTIP_LENGTH]; SDL_snprintf(health, sizeof(health), "%d", (int)std::ceil(game.sim.match.health_get())); float width = 0; float height = 0; int perRow = 1; bool isFit = false; for (int style = 0; style < RIVAL_STYLES && !isFit; style++) { layout.font = RIVAL_FONTS[style]; layout.isIconShown = style < RIVAL_ICON_STYLES; layout.size = layout.isIconShown ? RIVAL_SIZES[style] : ICON_TINY; layout.line = game.text.line_height_get(layout.font); float text = 0; float ping = 0; for (int k = 0; k < layout.count; k++) { int fighter = layout.fighters[k]; ping = std::max(ping, rival_ping_get(game, fighter) == PING_NONE ? 0 : NAME_PING_GAP + PING_BARS_WIDTH); text = std::max(text, game.text.size_get(name_get(game, fighter), layout.font).x); } float number = layout.isIconShown ? game.text.size_get(health, layout.font).x + RIVAL_PADDING : 0; text = isRace ? text + ping : std::max(text, number + RIVAL_BAR_MIN + ping); // No more than a few of a kind side by side, wrapping onto more rows to keep to that. Tiny icons can wrap // further to fit. float cell = ICON_PIXELS[layout.size] + RIVAL_ICON_GAP; int rowsMin = std::max(1, (std::max(slots[0], slots[1]) + RIVAL_COLUMNS_MAX - 1) / RIVAL_COLUMNS_MAX); int rowsMax = layout.isIconShown && layout.size == ICON_TINY ? SLOT_MAX : rowsMin; for (int rows = rowsMin; rows <= rowsMax && !isFit; rows++) { layout.rows = layout.isIconShown ? rows : 0; for (ItemKind kind : ITEM_KINDS) layout.columns[kind] = (slots[kind] + rows - 1) / rows; float icons = layout.isIconShown ? (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP : 0; width = std::ceil(std::max(icons, text) + inset * 2); height = inset * 2 + layout.line + (isRace ? 0 : RIVAL_PADDING + layout.line) + (layout.isIconShown ? RIVAL_PADDING + rows * cell - RIVAL_ICON_GAP : 0); perRow = std::clamp((int)((HUD_COLUMN_WIDTH + RIVAL_GAP) / (width + RIVAL_GAP)), 1, layout.count); int cardRows = (layout.count + perRow - 1) / perRow; perRow = (layout.count + cardRows - 1) / cardRows; bool isFullTall = layout.size == ICON_FULL && cardRows * layout.rows > RIVAL_FULL_ICON_ROWS_MAX; isFit = width <= HUD_COLUMN_WIDTH && cardRows <= RIVAL_ROWS_MAX && !isFullTall && cardRows * (height + RIVAL_GAP) - RIVAL_GAP <= room; } } if (!isFit) { perRow = (layout.count + RIVAL_ROWS_MAX - 1) / RIVAL_ROWS_MAX; width = std::floor((HUD_COLUMN_WIDTH - (perRow - 1) * RIVAL_GAP) / perRow); } for (int k = 0; k < layout.count; k++) { int row = k / perRow; int inRow = std::min(perRow, layout.count - row * perRow); float x = CANVAS_WIDTH - HOTBAR_MARGIN - inRow * width - (inRow - 1) * RIVAL_GAP; float y = CANVAS_HEIGHT - HOTBAR_MARGIN - height - row * (height + RIVAL_GAP); layout.cards[k] = {std::round(x + (k % perRow) * (width + RIVAL_GAP)), y, width, height}; } return layout; } // Every other fighter's window in a match, bordered in its color: its name centerd (cut short if it doesn't fit), its // health (online with its connection's bars after), then (with room) its weapons, a divider, and its trinkets, the // weapon in hand lit. Each is laid over the game as one piece, half see-through (half again for a fighter out of the // round), and fades by half again while the mouse is over it. void Ui::rivals_draw(Game& game) { Match& match = game.sim.match; if (match.state == MATCH_PRACTICE || match.state == MATCH_COUNTDOWN) return; Renderer& renderer = game.renderer; RivalLayout layout = rivals_layout(game); float inset = PANEL_BORDER + RIVAL_PADDING; float iconSize = ICON_PIXELS[layout.size]; float cell = iconSize + RIVAL_ICON_GAP; for (int k = 0; k < layout.count; k++) { int index = layout.fighters[k]; Fighter& fighter = game.sim.fighters[index]; Rect card = layout.cards[k]; float base = RIVAL_ALPHA * (fighter.isAlive || match.is_racing() || index == spectated ? 1 : RIVAL_OUT_SHARE); Color color = game.color_get(index); Color dim = color_shade(color, SECONDARY_SHADE); renderer.layer_begin(); panel_draw(renderer, card, color); float y = card.y + inset; Font font = layout.font; // The connection's bars go at the end of the health row, or beside the name in a race. bool isRace = match.is_racing(); float ping = rival_ping_get(game, index); float pingWidth = ping == PING_NONE ? 0 : NAME_PING_GAP + PING_BARS_WIDTH; float nameRoom = card.w - inset * 2 - (isRace ? pingWidth : 0); std::string name = name_get(game, index); std::string shown = name; while (game.text.size_get(shown.c_str(), font).x > nameRoom && !name.empty()) { name.pop_back(); shown = name + "..."; } Vec2 size = game.text.size_get(shown.c_str(), font); float left = std::round(card.x + (card.w - size.x - (isRace ? pingWidth : 0)) / 2); game.text.draw(renderer, shown.c_str(), {.color = color, .position = {left, std::round(y + (layout.line - size.y) / 2)}}, font); float pingBottom = std::round(y + (layout.line + PING_BARS_HEIGHT) / 2); if (isRace && ping != PING_NONE) ping_bars_draw(game, {left + size.x + NAME_PING_GAP, pingBottom}, ping, 1); y += layout.line + RIVAL_PADDING; // Its health as a bar, with the number before it when there's room. if (!isRace) { char text[TOOLTIP_LENGTH]; SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health))); size = game.text.size_get(text, font); float x = card.x + inset; float right = card.x + card.w - inset - pingWidth; if (size.x + RIVAL_PADDING + RIVAL_BAR_MIN <= right - x) { game.text.draw(renderer, text, {.color = color, .position = {x, std::round(y + (layout.line - size.y) / 2)}}, font); x += size.x + RIVAL_PADDING; } float barHeight = std::min(HEALTH_BAR_HEIGHT, layout.line - 1); Rect bar = {x, std::round(y + (layout.line - barHeight) / 2), right - x, barHeight}; bar_draw(renderer, bar, std::clamp(fighter.health / match.health_get(), 0.0f, 1.0f), color); if (ping != PING_NONE) ping_bars_draw(game, {right + NAME_PING_GAP, pingBottom + layout.line + RIVAL_PADDING}, ping, 1); y += layout.line + RIVAL_PADDING; } // Weapons then trinkets, each in its columns, centerd with the divider between. if (!layout.isIconShown) { renderer.layer_end(base * hover_alpha_get(rivalHovers[index])); continue; } float iconsWidth = (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP; float x = std::round(card.x + (card.w - iconsWidth) / 2); for (ItemKind kind : ITEM_KINDS) { int slots = fighter.slots_get(kind); int* loadout = fighter.loadout_get(kind); for (int slot = 0; slot < slots; slot++) { int item = loadout[slot]; if (item < 0) continue; bool isLit = kind == ITEM_TRINKET || slot == fighter.slot; Vec2 center = {x + slot % layout.columns[kind] * cell + iconSize / 2, y + slot / layout.columns[kind] * cell + iconSize / 2}; icon_draw(game, kind, item, {.color = isLit ? color : dim, .position = center}, layout.size); } x += layout.columns[kind] * cell; if (kind == ITEM_WEAPON) { renderer.rect_draw({x, y, RIVAL_DIVIDER, layout.rows * cell - RIVAL_ICON_GAP}, dim); x += RIVAL_DIVIDER + RIVAL_ICON_GAP; } } renderer.layer_end(base * hover_alpha_get(rivalHovers[index])); } } // Choosing items with several players on this screen: a panel each below top (side by side for two, a 2x2 grid for // more). void Ui::picks_draw(Game& game, float top) { int players[MAX_FIGHTERS]; int count = 0; for (int i = 0; i < game.sim.fighterCount; i++) if (game.is_local(i)) players[count++] = i; int columns = std::min(count, 2); int rows = (count + 1) / 2; float width = (CANVAS_WIDTH - HOTBAR_MARGIN * 2 - LOADOUT_GAP * (columns - 1)) / columns; float height = (CANVAS_HEIGHT - HOTBAR_MARGIN - top - LOADOUT_GAP * (rows - 1)) / rows; for (int k = 0; k < count; k++) pick_panel_draw(game, players[k], {std::round(HOTBAR_MARGIN + k % columns * (width + LOADOUT_GAP)), std::round(top + k / columns * (height + LOADOUT_GAP)), std::floor(width), std::floor(height)}); } // One player's choosing panel in their color: their name, which kind they're choosing (bumpers switch when both // are), the highlighted item's name, whether they're ready, the items to choose from and the slots they fill. On a // gamepad the D-pad or stick moves the highlight, A puts the item in the first empty slot (or the last) and B empties // the last filled slot. The keyboard's player can also click items in, click a slot to fill it, right-click to empty // it, or press a slot's number. void Ui::pick_panel_draw(Game& game, int index, Rect panel) { Match& match = game.sim.match; Input& input = game.input; Fighter& fighter = game.sim.fighters[index]; int device = game.devices[index]; bool isKeyboard = device == DEVICE_KEYBOARD; ItemKind& kind = pickKinds[index]; bool isBoth = match.isPicks[ITEM_WEAPON] && match.isPicks[ITEM_TRINKET]; kind = match.isPicks[ITEM_TRINKET] && (kind == ITEM_TRINKET || !match.isPicks[ITEM_WEAPON]) ? ITEM_TRINKET : ITEM_WEAPON; if (isBoth && (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) || input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER))) kind = kind == ITEM_TRINKET ? ITEM_WEAPON : ITEM_TRINKET; Color previousTint = tint; Color previousShade = shade; tint = game.color_get(index); shade = color_shade(tint, SECONDARY_SHADE); panel_draw(game.renderer, panel, tint); int items = item_count_get(kind); float step = SLOT_SIZE + SLOT_GAP; int columns = std::max(1, (int)((panel.w - PICK_PADDING * 2 + SLOT_GAP) / step)); int& cursor = pickCursors[index]; int nav = input.nav_get(device); cursor += (nav & NAV_RIGHT ? 1 : 0) - (nav & NAV_LEFT ? 1 : 0); cursor += ((nav & NAV_DOWN ? 1 : 0) - (nav & NAV_UP ? 1 : 0)) * columns; cursor = std::clamp(cursor, 0, items - 1); int slots = fighter.slots_get(kind); int* loadout = fighter.loadout_get(kind); float x = panel.x + PICK_PADDING; float y = panel.y + PICK_PADDING; char text[TOOLTIP_LENGTH]; const char* heading = isBoth ? (kind == ITEM_TRINKET ? "Trinkets (Bumpers)" : "Weapons (Bumpers)") : (kind == ITEM_TRINKET ? "Trinkets" : "Weapons"); const char* item = item_name_get(kind, cursor); SDL_snprintf(text, sizeof(text), "%s - %s: %s", name_get(game, index), heading, item); game.text.draw(game.renderer, text, {.color = tint, .position = {x, y}}); const char* ready = match.isReady[index] ? "Ready!" : "Not Ready"; float readyWidth = game.text.size_get(ready).x; game.text.draw( game.renderer, ready, {.color = match.isReady[index] ? tint : shade, .position = {panel.x + panel.w - PICK_PADDING - readyWidth, y}}); y += LINE_HEIGHT + PICK_PADDING; for (int i = 0; i < items; i++) { Rect box = {x + i % columns * step, y + i / columns * step, SLOT_SIZE, SLOT_SIZE}; if (isKeyboard && is_point_in_rect(input.mouse, box)) { cursor = i; if (input.isClicked) { int empty = std::find(loadout, loadout + slots, -1) - loadout; pick_send(game, index, kind, std::min(empty, slots - 1), i); } } slot_draw(game, {.rect = box, .item = i, .isHoverable = isKeyboard, .isLit = i == cursor, .kind = kind}); } y += (items + columns - 1) / columns * step + PICK_PADDING; for (int slot = 0; slot < slots; slot++) { Rect box = {x + slot * step, y, SLOT_SIZE, SLOT_SIZE}; bool isHovered = isKeyboard && is_point_in_rect(input.mouse, box); if (isHovered && input.isClicked) pick_send(game, index, kind, slot, cursor); if (isHovered && input.isRightClicked) pick_send(game, index, kind, slot, -1); slot_draw(game, {.rect = box, .item = loadout[slot], .label = slot + 1, .isHoverable = isKeyboard, .isLit = loadout[slot] >= 0, .kind = kind}); } if (isKeyboard && input.slotPressed >= 0 && input.slotPressed < slots) pick_send(game, index, kind, input.slotPressed, cursor); if (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_SOUTH)) { int empty = std::find(loadout, loadout + slots, -1) - loadout; pick_send(game, index, kind, std::min(empty, slots - 1), cursor); } int filled = slots - 1; while (filled >= 0 && loadout[filled] < 0) filled--; if (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_EAST) && filled >= 0) pick_send(game, index, kind, filled, -1); tint = previousTint; shade = previousShade; } // Sends one player's pick through their controls on the next tick, with its sound. void Ui::pick_send(Game& game, int fighter, ItemKind kind, int slot, int item) { game.input.edit_send(game.devices[fighter], pick_encode(kind, slot, item)); game.audio.play(item < 0 ? SOUND_UNEQUIP : SOUND_EQUIP); } // Starts a frame of focusable widgets. Last frame's widgets are where the focus can move: the nearest one in the // direction pressed (a focused number or slider takes left and right instead). Up or down with nothing that way asks // the page or grid the focus is in to scroll (see scroll_take), and tries again next frame once it has. void Ui::focus_begin(Game& game) { Input& input = game.input; FocusTarget* last = targets[targetFrame]; int lastCount = targetCounts[targetFrame]; targetFrame ^= 1; targetCounts[targetFrame] = 0; isFocusing = true; int nav = input.nav_get(DEVICE_KEYBOARD) | navRetry; bool isCapturing = input.captureControl >= 0 || active; if (isCapturing) nav = 0; navRetry = 0; scrollWant = 0; isScrolled = false; adjust = 0; if (input.isMouseMoved || input.isClicked || input.isRightClicked || input.scroll != 0) isFocusShown = false; if (nav || input.isConfirmed || input.pad_presses_get(DEVICE_KEYBOARD)) isFocusShown = true; isConfirmed = isFocusShown && !isCapturing && (input.isConfirmed || input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_SOUTH)); if (!isFocusShown || nav == 0) return; FocusTarget* from = nullptr; for (int i = 0; i < lastCount; i++) if (last[i].id == focusId && last[i].sub == focusSub) from = &last[i]; if (!from) return; int across = NAV_LEFT | NAV_RIGHT; if (from->isAdjustable && (nav & across)) { adjust = (nav & NAV_RIGHT ? 1 : 0) - (nav & NAV_LEFT ? 1 : 0); nav &= ~across; } Rect a = from->rect; for (int direction : {NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT}) { if (!(nav & direction)) continue; bool isVertical = direction == NAV_UP || direction == NAV_DOWN; // The gap to each widget that way, and how far it sits off to the side: up and down take the nearest row, // then the widget in it most in line; left and right only move along the same row. int best = -1; float bestAhead = 0; float bestAside = 0; for (int i = 0; i < lastCount; i++) { Rect b = last[i].rect; if (&last[i] == from) continue; float ahead = direction == NAV_UP ? a.y - (b.y + b.h) : direction == NAV_DOWN ? b.y - (a.y + a.h) : direction == NAV_LEFT ? a.x - (b.x + b.w) : b.x - (a.x + a.w); float center = isVertical ? (b.y + b.h / 2) - (a.y + a.h / 2) : (b.x + b.w / 2) - (a.x + a.w / 2); bool isAhead = (direction == NAV_UP || direction == NAV_LEFT) ? center < -1 : center > 1; float aside = isVertical ? std::max({0.0f, b.x - (a.x + a.w), a.x - (b.x + b.w)}) : std::max({0.0f, b.y - (a.y + a.h), a.y - (b.y + b.h)}); if (!isAhead || (!isVertical && aside > 0)) continue; ahead = std::max(0.0f, ahead); aside += std::abs(isVertical ? b.x - a.x : 0) * FOCUS_ALIGN_WEIGHT; bool isNearerRow = best < 0 || ahead < bestAhead - FOCUS_ROW_SLACK; bool isSameRow = best >= 0 && std::abs(ahead - bestAhead) <= FOCUS_ROW_SLACK; if (isNearerRow || (isSameRow && aside < bestAside)) { best = i; bestAhead = ahead; bestAside = aside; } } if (best >= 0) { focusId = last[best].id; focusSub = last[best].sub; game.audio.play(SOUND_SELECT); return; } if (isVertical) scrollWant = direction; return; } } // Ends the frame's focusable widgets, landing the focus on one if it's on none of them (the preferred one, or the // first), and marking its corners. void Ui::focus_end(Game& game) { isFocusing = false; if (scrollWant && isScrolled) navRetry = scrollWant; FocusTarget* now = targets[targetFrame]; int count = targetCounts[targetFrame]; if (!isFocusShown || count == 0) return; FocusTarget* focused = nullptr; for (int i = 0; i < count; i++) if (now[i].id == focusId && now[i].sub == focusSub) focused = &now[i]; if (!focused) { focused = &now[0]; for (int i = count - 1; i >= 0; i--) if (now[i].isPreferred) focused = &now[i]; focusId = focused->id; focusSub = focused->sub; } // A bracket at each corner, just outside it. Rect rect = focused->rect; float left = rect.x - FOCUS_OUTSET; float top = rect.y - FOCUS_OUTSET; float right = rect.x + rect.w + FOCUS_OUTSET - 1; float bottom = rect.y + rect.h + FOCUS_OUTSET - 1; for (float x : {left, right}) for (float y : {top, bottom}) { float armX = x == left ? x : x - FOCUS_ARM + 1; float armY = y == top ? y : y - FOCUS_ARM + 1; game.renderer.rect_draw({armX, y, FOCUS_ARM, 1}, tint); game.renderer.rect_draw({x, armY, 1, FOCUS_ARM}, tint); } } // Adds a widget the focus can move to, returning whether it has the focus. Disabled widgets, and any outside focus // begin and end, can't. bool Ui::focus_add(const void* id, int sub, Rect rect, bool isAdjustable, bool isPreferred) { int& count = targetCounts[targetFrame]; if (!isFocusing || !isWidgetLive || !isWidgetEnabled || count >= FOCUS_TARGETS_MAX) return false; targets[targetFrame][count++] = {rect, id, sub, isAdjustable, isPreferred}; bool isFocused = isFocusShown && id == focusId && sub == focusSub; if (isFocused) focusRect = rect; return isFocused; } bool Ui::is_focused(const void* id, int sub) { return isFocusing && isFocusShown && id == focusId && sub == focusSub; } // Which way (-1 up, 1 down) a page or grid in area should scroll for the focus, if it's in there and wants to move // past the rows shown. Whatever scrolls says so with scroll_mark, so the move is tried again. int Ui::scroll_take(Rect area) { Vec2 center = {focusRect.x + focusRect.w / 2, focusRect.y + focusRect.h / 2}; if (!scrollWant || !isFocusShown || !is_point_in_rect(center, area)) return 0; return scrollWant == NAV_UP ? -1 : 1; } void Ui::scroll_mark() { isScrolled = true; } // Online, in the lobby's sandbox, each queued game: its area on the ground in the queuer's color (a mark on the floor // for each place in it, lit once taken), the game's mode icon and a "!" bobbing over the queuer, and under the area, // how many have readied up and (standing in it) the key to ready up. Hovering the queuer or its icon shows the game. void Ui::queues_draw(Game& game) { hoveredQueue = -1; Match& match = game.sim.match; if (!game.session.isActive || match.state != MATCH_PRACTICE) return; Renderer& renderer = game.renderer; Input& input = game.input; Vec2 mouse = input.mouse + renderer.camera; float bob = std::round(math_sin(SDL_GetTicks() / 1000.0f * QUEUE_ICON_SPEED) * QUEUE_ICON_BOB); for (int q = 0; q < game.sim.fighterCount; q++) { Queue& queue = match.queues[q]; if (!queue.isActive) continue; Color color = game.color_get(q, TINT_PLAIN); Rect area = queue.area; int members[MAX_FIGHTERS]; int count = match.queue_members_get(game.sim, q, members); renderer.rect_draw(area, color_fade(color, QUEUE_FILL_ALPHA)); Color edge = color_fade(color, QUEUE_EDGE_ALPHA); renderer.rect_draw({area.x, area.y, 1, area.h}, edge); renderer.rect_draw({area.x + area.w - 1, area.y, 1, area.h}, edge); renderer.rect_draw({area.x, area.y, area.w, 1}, edge); for (int k = 0; k < queue.rules.players; k++) { float x = std::round(area.x + QUEUE_MARGIN + k * QUEUE_SLOT_WIDTH + 2); renderer.rect_draw({x, area.y + area.h - 2, QUEUE_SLOT_WIDTH - 4, 2}, k < count ? color : color_shade(color, SECONDARY_SHADE)); } Fighter& queuer = game.sim.fighters[q]; Rect box = queuer.body.box; Vec2 icon = {std::round(box.x + box.w / 2), std::round(game.scene.top_get(queuer) - QUEUE_ICON_RISE + bob)}; float bang = game.text.size_get("!", FONT_SMALL, 2).x; icon.x -= std::round(bang / 2); game.sprites.modes.draw(renderer, "Deathmatch", 0, {.color = color, .position = icon}); game.text.draw(renderer, "!", {.color = color, .position = {icon.x + ICON_SIZE / 2, icon.y - ICON_SIZE / 2}, .scale = {2, 2}}); Rect iconRect = {icon.x - ICON_SIZE / 2, icon.y - ICON_SIZE / 2, ICON_SIZE + bang, ICON_SIZE}; if (is_point_in_rect(mouse, box) || is_point_in_rect(mouse, iconRect)) hoveredQueue = q; int ready = 0; bool isLocalIn = false; for (int k = 0; k < count; k++) { ready += match.isQueueReady[members[k]]; isLocalIn |= members[k] == game.local; } char line[TOOLTIP_LENGTH]; if (queue.readyTicks > 0) SDL_strlcpy(line, "Starting!", sizeof(line)); else SDL_snprintf(line, sizeof(line), "%d/%d Ready", ready, queue.rules.players); float y = area.y + area.h + QUEUE_TEXT_GAP; Vec2 size = game.text.size_get(line); game.text.draw(renderer, line, {.color = color, .position = {std::round(area.x + (area.w - size.x) / 2), y}}); if (!isLocalIn) continue; char key[TOOLTIP_LENGTH]; input.control_name_get(CONTROL_READY, key, sizeof(key)); SDL_snprintf(line, sizeof(line), "%s: %s", key, match.isQueueReady[game.local] ? "Unready" : "Ready"); size = game.text.size_get(line); game.text.draw(renderer, line, {.color = WHITE, .position = {std::round(area.x + (area.w - size.x) / 2), y + LINE_HEIGHT}}); } } // The game a hovered queuer has queued, in a tooltip by the mouse. void Ui::queue_tooltip_draw(Game& game) { if (hoveredQueue < 0 || hoveredQueue >= game.sim.fighterCount || game.menu.isOpen) return; const Rules& rules = game.sim.match.queues[hoveredQueue].rules; Vec2 size = game_summary_size(game, rules); Vec2 mouse = game.input.mouse; Rect panel = {mouse.x + TOOLTIP_OFFSET, mouse.y + TOOLTIP_OFFSET, size.x + TOOLTIP_PADDING * 2, size.y + TOOLTIP_PADDING * 2}; panel.x = std::round(std::min(panel.x, CANVAS_WIDTH - panel.w)); panel.y = std::round(std::min(panel.y, CANVAS_HEIGHT - panel.h)); panel_draw(game.renderer, panel, game.color_get(hoveredQueue, TINT_PLAIN)); game_summary_draw(game, rules, {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}); }