Files
smack-a-brother/src/ui.cpp
T
shweet 93cdf46d5a Room and lobby chat, /host, /fashion, player-colored lines, context tips, and American spelling
- Chat goes to your room (the sandbox, or your game) by default, and to the whole lobby with /all or a leading !.
  Lines from elsewhere say where they came from ([Sandbox], [Game 2]), lobby-wide ones from your room say [All].
  Typing shows only to your room. Protocol 8.
- Hosting: everyone is told who hosts now, and the host can hand it over with /host "player".
- /fashion <item> wears a fashion locally, like /color.
- Lines that mention a player show in that player's color, looked up as they draw.
- Tips are tagged with the item and situations they fit, and those that fit are 1.5x as likely. They turn over every
  10 seconds on one continuous run, show under the menu too, and can be turned off with Tips in HUD / Gameplay. The
  HUD hotbars hide while the menu is up (except on its item tabs).
- American spelling throughout: color, center, gray, mustache, and so on.
2026-11-10 19:37:19 -05:00

3220 lines
145 KiB
C++

#include "ui.h"
#include "game.h"
#include <SDL3/SDL.h>
#include <algorithm>
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<int>(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<std::string> game_summary_lines(const Rules& rules)
{
std::vector<std::string> 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<std::string> 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});
}