Files
smack-a-brother/src/ui.cpp
T
shweetandClaude Opus 5.5 2007fb7edc Initial commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:30:33 -05:00

2778 lines
123 KiB
C++

#include "ui.h"
#include "game.h"
#include <SDL3/SDL.h>
#include <algorithm>
constexpr int CHAMFER = 2;
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;
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_OUT_ALPHA = 0.25f;
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_FADE = 0.15f;
constexpr float RIVAL_PADDING = 2;
constexpr int RIVAL_COLUMNS_MAX = 3;
constexpr int RIVAL_FULL_SLOTS = 4;
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 float CHEVRON_INSET = 3;
constexpr float CHEVRON_STEP = 3;
constexpr float CHARGE_BAR_HEIGHT = 8;
constexpr float CHARGE_BAR_WIDTH = 20;
constexpr float DPS_SCALE = 2;
constexpr float FIGHT_BANNER_TIME = 0.6f;
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;
constexpr float PANEL_BORDER = 2;
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_HEIGHT = 7;
constexpr float COOLDOWN_FADE_IN = 0.1f;
constexpr float COOLDOWN_FADE_OUT = 0.25f;
constexpr float COOLDOWN_GAP = 4;
constexpr float COOLDOWN_SPACING = 26;
constexpr float COOLDOWN_COMPACT_SPACING = 14;
constexpr float COOLDOWN_SLIDE = 12;
constexpr int COOLDOWN_FULL_ROWS = 5;
constexpr float COOLDOWN_VISIBLE = 0.25f;
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 SCROLL_WIDTH = 4;
constexpr float SEPARATOR_GAP = 4;
constexpr float FOLD_ARROW = 3;
constexpr float PREVIEW_CAPTION_GAP = 8;
constexpr float PREVIEW_FEET = 120;
constexpr float PREVIEW_NAME_Y = 16;
constexpr float PREVIEW_SCALE = 3;
constexpr const char PREVIEW_ANIMATION[] = "Walk Right";
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;
constexpr float TOOLTIP_OFFSET = 12;
constexpr float TOOLTIP_PADDING = 6;
constexpr float PREVIEW_LINE_MAX = 200;
constexpr float TABLE_GAP = 8;
constexpr float TABLE_SPACE = 4;
constexpr const char* TABLE_HEADERS[] = {"Attack", "Charged"};
constexpr float TRACK_DOT = 6;
constexpr float TRACK_HEIGHT = 2;
constexpr float TRACK_WIDTH = 200;
constexpr float SECONDS_PER_MINUTE = 60;
constexpr Vec2 LABEL_OFFSET = {4, 2};
constexpr const char* TIPS[] = {
"Big hits launch fighters, and slamming into walls while launched hurts.",
"Swing the Hammer into a wall to rebound off it, even mid-launch.",
"Ranged shots lose damage the farther they fly. Get close.",
"Explosions hit hardest at their centre.",
"Your own explosions push you but never hurt you. Rocket jump!",
"Arrows stuck in walls are solid to you. Stand on them.",
"Charged boomerangs are platforms only you can ride.",
"Standing in your own flames brakes you, even mid-launch.",
"Shots carry your momentum. Fire while charging forward for extra damage.",
"Every hit is stronger the faster you're moving.",
"Let go of a charge early for a normal attack that hits harder.",
"Mines need a moment to arm before they'll go off.",
"The Whip's charged blast only lands at the very tip.",
"A charged grapple hit drags you straight to your target.",
"Platforms block shots. Use them as cover.",
"With Iron Weights, hold Down to slam the ground. Faster falls hit harder.",
"Ricochet shots add damage with every bounce.",
"Your charge carries over when you swap weapons.",
"Charge recalls your boomerangs once they're all out.",
"Attack or Charge reels a thrown flail ball back in.",
"Uzi and Blaster hits barely push. Melee and explosions send fighters flying.",
"Rapid small hits can't juggle you. Knockback tops up, it doesn't stack.",
"Crack the Whip against a wall to be pulled toward it.",
"Recoil is movement. Fire away from where you want to go.",
"Splitter copies fan wider with each level.",
"Dual Wield fires your previous weapon from your off hand too.",
"Tesla Coil chains lightning between your weapons out in the world.",
"Each round levels up every item, and health rises with it.",
"With the Feather, hold Up in the air to float.",
"The Lance's charged poke lunges you forward.",
"In a race, dithered pits send you back to the last solid ground you stood on.",
"Race blocks grow back a few seconds after they break. Don't dawdle.",
"Hits in a race don't hurt, but they still knock. Push rivals into pits.",
"Shotgun recoil can double as a jump.",
};
static CountdownInfo countdown_info_get(Game& game, bool isPad);
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.match.owners[fighter] >= 0)
{
std::string name = game.session.base_name_get(game, game.match.owners[fighter]);
int same = 1;
for (int i = 0; i < fighter; i++)
same += game.match.owners[i] >= 0 && game.session.base_name_get(game, game.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();
tint = isWatching ? WHITE : game.color_get(game.local, true);
shade = color_shade(tint, SECONDARY_SHADE);
Vec2 mouse = game.input.mouse;
// The menu covers the match overlays. Hotbars sit under them, except in the sandbox menu they slide into. With
// several players on this screen in a match, each gets a compact panel along the bottom instead.
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.match.state != MATCH_PRACTICE && game.local_count() > 1;
if (!isBarsRaised && !isShared && !isWatching)
{
hotbar_draw(game);
cooldowns_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.match.state == MATCH_COUNTDOWN && game.menu.isOpen && !isShared)
countdown_aside_draw(game, game.input.devices[DEVICE_KEYBOARD].isAiming);
if (isBarsRaised)
hotbar_draw(game);
crosshairs_draw(game, camera);
queue_tooltip_draw(game);
if (active && !isActiveSeen)
widget_release(game);
isActiveSeen = false;
}
// Moves a level by step within range with a sound. Outside the menu it also pops up above the player.
void Ui::level_step(Game& game, int& level, int step)
{
int next = std::clamp(level + step, 0, LEVEL_COUNT - 1);
if (next == level)
return;
bool isUp = next > level;
level = next;
game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN);
if (game.menu.isOpen)
return;
Fighter& fighter = game.local_get();
Vec2 head = {fighter.center_get().x, fighter.body.box.y};
game.effects.popup_spawn(head, isUp ? "Level Up" : "Level Down", isUp ? tint : shade);
}
// 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 colour, then the local fighter's charge bar while charging, which blinks once full.
void Ui::bars_draw(Game& game)
{
Match& match = game.match;
Renderer& renderer = game.renderer;
for (int i = 0; i < game.fighterCount; i++)
{
Fighter& fighter = game.fighters[i];
if (!fighter.isAlive)
continue;
Rect box = fighter.body.box;
float centerX = box.x + box.w / 2;
float bottom = std::round(box.y - 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.fighterCount > 1);
if (isNamed)
{
// Online, the player's connection beside it, the two centred together.
const char* name = name_get(game, i);
Vec2 size = game.text.size_get(name);
bottom -= size.y;
float ping = game.session.isActive ? game.session.ping_get(game, match.owners[i]) : PING_NONE;
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())
continue;
float fill = (fighter.charge - CHARGE_VISIBLE) / (1 - CHARGE_VISIBLE);
bool isShown = fighter.charge < 1 || fighter.is_flashing();
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 bordered bar filled from the left by fill, from 0 to 1.
void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color)
{
panel_draw(renderer, bar, color);
renderer.rect_draw({bar.x + PANEL_BORDER, bar.y + PANEL_BORDER, std::round((bar.w - PANEL_BORDER * 2) * fill),
bar.h - PANEL_BORDER * 2},
color);
}
// In practice, a hint to open the menu (with its key), fading once it first opens. 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.match;
char text[TOOLTIP_LENGTH];
isTipChosen &= match.state == MATCH_COUNTDOWN;
Input& input = game.input;
bool isPad = input.devices[DEVICE_KEYBOARD].isAiming;
if (match.state == MATCH_PRACTICE)
{
char key[TOOLTIP_LENGTH];
char reset[TOOLTIP_LENGTH];
Binding menu = input.bindings[CONTROL_MENU];
Binding ready = input.bindings[CONTROL_READY];
input.binding_name_get(isPad ? menu.pad : menu.key, isPad, key, sizeof(key));
input.binding_name_get(isPad ? ready.pad : ready.key, isPad, reset, sizeof(reset));
SDL_snprintf(text, sizeof(text), "%s: Menu %s: Reset", key, reset);
text_center_draw(game, text, CANVAS_HEIGHT - HOTBAR_MARGIN - LINE_HEIGHT, 1, hintAlpha);
return;
}
bool isRace = match.rules.mode == MODE_RACE;
if (isRace)
race_draw(game);
else
{
// The score: each fighter's wins in its own colour, the dashes between them white. It's centred along the top,
// unless a long weapon hotbar reaches it, when it moves right to clear the hotbar (the clock under it moves
// with it).
Renderer& renderer = game.renderer;
int length = 0;
for (int i = 0; i < game.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;
float hotbarRight = HOTBAR_MARGIN + game.local_get().weaponSlots * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP;
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.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)}});
}
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;
CountdownInfo info = countdown_info_get(game, isPad);
bool isLocalReady = match.isReady[game.local];
if (!isTipChosen)
tip = random_get(0, std::size(TIPS));
isTipChosen = true;
if (game.menu.isOpen)
break;
SDL_snprintf(text, sizeof(text), "%s %d", info.round, info.seconds);
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.fighterCount, info.key);
text_center_draw(game, text, y, 1);
y += LINE_HEIGHT + READY_GAP;
if (game.local_count() > 1 && (match.isWeaponPick || match.isTrinketPick))
{
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;
SDL_snprintf(text, sizeof(text), "Tip: %s", TIPS[tip]);
text_center_draw(game, text, y, 1);
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)
{
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);
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 colour.
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);
if (!game.session.isActive)
{
text_center_draw(game, isPad ? "B: Sandbox" : "Esc: Sandbox", 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())
{
Binding binding = input.bindings[CONTROL_READY];
char key[TOOLTIP_LENGTH];
input.binding_name_get(isPad ? binding.pad : binding.key, isPad, key, sizeof(key));
SDL_snprintf(text, sizeof(text), "%s: Play Again Esc: Leave", key);
text_center_draw(game, text, y, 1);
y += LINE_HEIGHT * 2;
}
for (int i = 0; i < game.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, true));
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 colour showing how far along the course
// it is.
void Ui::race_draw(Game& game)
{
char text[TOOLTIP_LENGTH];
time_format(text, sizeof(text), game.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.world.size_get().x;
for (int i = 0; i < game.fighterCount; i++)
{
Fighter& fighter = game.fighters[i];
float progress = game.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 colour, with its time (or DNF). Returns the y
// below the last line.
float Ui::results_draw(Game& game, float y)
{
Match& match = game.match;
int order[MAX_FIGHTERS];
for (int i = 0; i < game.fighterCount; i++)
order[i] = i;
auto rank = [&](int fighter) {
return match.finishTimes[fighter] >= 0 ? match.finishTimes[fighter] : INFINITY;
};
std::stable_sort(order, order + game.fighterCount, [&](int a, int b) { return rank(a) < rank(b); });
for (int place = 0; place < game.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 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, false);
if (!summary.isDamaging)
summary = attack_summary_get(fighter, weapon, level, true);
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 centred 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 (int kind = 0; kind < 2 && style != LOADOUT_ICONS; kind++)
{
bool isTrinket = kind == 1;
for (int i = 0; i < counts[kind]; i++)
{
int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[i];
if (item < 0)
continue;
int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i];
Tooltip& tooltip = layout.tooltips[kind][i] = tooltip_build(game, isTrinket, item, level);
tooltip.flavor[0] = '\0';
tooltip.rowCount = 0;
if (isTrinket && !isBrief)
SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)\n%s", level + 1,
TRINKET_DEFS[item].effects[level]);
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 (int kind = 0; kind < 2; kind++)
{
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 == 1 ? divide : 0;
layout.panels[kind][i] = {cell % columns * (width + LOADOUT_GAP) + shift,
rowTop + cell / columns * (height + LOADOUT_GAP), width, height};
}
if (isShared && kind == 0 && 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 == 0 && 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 (int kind = 0; kind < 2; kind++)
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 (int kind = 0; kind < 2; kind++)
{
bool isTrinket = kind == 1;
for (int i = 0; i < counts[kind]; i++)
{
int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[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 = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i],
.isLit = true,
.isTrinket = isTrinket});
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);
icon_draw(game, isTrinket, item,
{.color = color,
.position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2,
panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}});
}
}
}
// Text centred across the canvas with its top at y.
// What the countdown shows: the round's name, whole seconds left, how many are ready, and the key that readies up.
static CountdownInfo countdown_info_get(Game& game, bool isPad)
{
Match& match = game.match;
CountdownInfo info;
if (match.rules.mode == MODE_RACE)
SDL_strlcpy(info.round, "Race", sizeof(info.round));
else if (match.is_final_round(game))
SDL_strlcpy(info.round, "Final Round", sizeof(info.round));
else
SDL_snprintf(info.round, sizeof(info.round), "Round %d", match.round + 1);
info.seconds = (int)std::ceil(match.timer);
for (int i = 0; i < game.fighterCount; i++)
info.ready += match.isReady[i];
Binding binding = game.input.bindings[CONTROL_READY];
game.input.binding_name_get(isPad ? binding.pad : binding.key, isPad, info.key, sizeof(info.key));
return info;
}
// 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, bool isPad)
{
CountdownInfo info = countdown_info_get(game, isPad);
const char* round = info.round;
int seconds = info.seconds;
int ready = info.ready;
const char* key = game.match.isReady[game.local] ? 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);
};
float labelScale = game.text.size_get(round, FONT_SMALL, ASIDE_LABEL_SCALE).x <= margin - ASIDE_PADDING * 2
? ASIDE_LABEL_SCALE
: 1;
float height =
game.text.line_height_get(FONT_SMALL, labelScale) + game.text.line_height_get(FONT_SMALL, ROUND_SCALE);
float y = middle - height / 2;
y += line_draw(round, margin / 2, y, labelScale);
char text[TOOLTIP_LENGTH];
SDL_snprintf(text, sizeof(text), "%d", seconds);
line_draw(text, margin / 2, y, ROUND_SCALE);
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.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);
}
y = BAR_Y + SLOT_SIZE + HINT_GAP + (isFocusShown ? LINE_HEIGHT : 0);
SDL_snprintf(text, sizeof(text), "Tip: %s", TIPS[tip]);
text_center_draw(game, text, y, 1);
}
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 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 y = HOTBAR_MARGIN;
float rowHeight = game.text.line_height_get(FONT_SMALL, DPS_SCALE);
if (game.match.state != MATCH_PRACTICE && !game.match.is_racing())
{
Fighter& fighter = game.local_get();
float maxHealth = game.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;
game.text.draw(
game.renderer, text,
{.color = tint, .position = {std::round(bar.x - STATUS_GAP - width), y}, .scale = {DPS_SCALE, DPS_SCALE}});
y += rowHeight;
}
// The DPS and speed meters each show only if they're on in the settings.
auto meter_draw = [&](const char* label) {
float width = game.text.size_get(label, FONT_SMALL, DPS_SCALE).x;
game.text.draw(game.renderer, label,
{.color = tint, .position = {std::round(right - width), y}, .scale = {DPS_SCALE, DPS_SCALE}});
y += rowHeight;
};
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);
}
}
// The weapon bar (lit on the selected slot) and the trinket bar (lit where filled). The menu decides where
// they sit. 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++)
{
bool isTrinket = i >= fighter.weaponSlots;
int index = isTrinket ? i - fighter.weaponSlots : i;
int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots;
int item = isTrinket ? fighter.trinketLoadout[index] : fighter.weaponLoadout[index];
// In the HUD, slots are dimmed until the mouse comes over them; under the menu they're at full brightness.
float hover = slotHovers[isTrinket][index];
Slot slot = {
.rect = game.menu.slot_rect(isTrinket, index, count),
.alpha = game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover,
.item = item,
.level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[index],
.label = index + 1,
.isHoverable = true,
.isLit = isTrinket ? item >= 0 : index == fighter.slot,
.isTrinket = isTrinket,
.isOpaque = true,
.isTarget = game.menu.target_get(game, isTrinket) == index,
};
slot_draw(game, slot);
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.isTrinket, hovered.item, hovered.level);
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.
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 base = slot.isLit || isHovered || slot.isTarget ? tint : shade;
Color color = slot.isOpaque ? color_shade(base, slot.alpha) : color_fade(base, slot.alpha);
float fill = slot.isOpaque ? 1 : slot.alpha;
panel_draw(renderer, slot.rect, color, fill);
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)
{
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, fill));
}
if (slot.item >= 0)
{
icon_draw(game, slot.isTrinket, slot.item,
{.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}});
Vec2 corner = {slot.rect.x + slot.rect.w - CHEVRON_INSET, slot.rect.y + slot.rect.h - CHEVRON_INSET};
for (int i = 0; i <= slot.level; i++)
game.sprites.ui.draw(renderer, "Chevron", 0,
{.color = color, .position = {corner.x, corner.y - i * CHEVRON_STEP}});
}
if (slot.label <= 0)
return;
char label[] = {char('0' + slot.label), 0};
game.text.draw(renderer, label,
{.color = color, .position = {slot.rect.x + LABEL_OFFSET.x, slot.rect.y + LABEL_OFFSET.y}});
}
// 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);
}
// 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,
bool isCharged)
{
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, isCharged ? "%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 deg", number);
}
break;
case 8:
if (summary.lunge != 0)
SDL_snprintf(text, size, summary.lunge > 0 ? "%g forward" : "%g back",
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. 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, bool isPreview, Tooltip& tooltip)
{
constexpr const char* LABELS[] = {"Fires", "Damage", "Every", "Range", "Blast",
"Speed", "Out at once", "Spread", "Lunge"};
const WeaponDef& def = WEAPON_DEFS[weapon];
tooltip.rowCount = 0;
for (int row = 0; row < (int)std::size(LABELS); row++)
{
StatRow& stat = tooltip.rows[tooltip.rowCount];
stat = {.label = LABELS[row]};
bool isShown = false;
for (int column = 0; column < 2; column++)
{
bool isCharged = column == 1;
const AttackDef& attack = isCharged ? def.charged : def.normal;
AttackSummary summary = attack_summary_get(fighter, weapon, level, isCharged);
char* value = stat.values[column];
stat_format(value, STAT_LENGTH, row, summary, attack, isCharged);
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, attack_summary_get(fighter, weapon, level + 1, isCharged), attack,
isCharged);
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;
}
}
// 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;
float widths[3] = {0, text.size_get(TABLE_HEADERS[0], font).x, text.size_get(TABLE_HEADERS[1], 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 < 2; 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);
return tooltip.rowCount > 0 ? Vec2{columns[2] + widths[2], (tooltip.rowCount + 1) * 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 < 2; 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);
}
// A trinket's effects at one level with the next level's after a ">", line by line. Where a line only changes in its
// numbers, just those show both values ("1.15x > 1.3x Attack Rate"); otherwise the whole line does ("Wall Cling > Wall
// Cling, Wall Kick"), split onto a line of its own after the first when that's too wide.
static void effects_preview_get(Game& game, const char* now, const char* next, char* text, int size)
{
auto lines_split = [](const char* source, std::vector<std::string>& lines) {
for (const char* start = source;;)
{
const char* end = SDL_strchr(start, '\n');
lines.emplace_back(start, end ? end - start : SDL_strlen(start));
if (!end)
break;
start = end + 1;
}
};
auto words_split = [](const std::string& line) {
std::vector<std::string> words;
size_t start = 0;
while (start <= line.size())
{
size_t end = line.find(' ', start);
words.push_back(line.substr(start, end == std::string::npos ? std::string::npos : end - start));
if (end == std::string::npos)
break;
start = end + 1;
}
return words;
};
auto is_number = [](const std::string& word) {
return std::any_of(word.begin(), word.end(), [](char c) { return c >= '0' && c <= '9'; });
};
std::vector<std::string> befores;
std::vector<std::string> afters;
lines_split(now, befores);
lines_split(next, afters);
std::string result;
for (size_t i = 0; i < std::max(befores.size(), afters.size()); i++)
{
std::string before = i < befores.size() ? befores[i] : "";
std::string after = i < afters.size() ? afters[i] : "";
std::vector<std::string> wordsBefore = words_split(before);
std::vector<std::string> wordsAfter = words_split(after);
bool isNumbers = !before.empty() && wordsBefore.size() == wordsAfter.size();
for (size_t w = 0; w < wordsBefore.size() && isNumbers; w++)
isNumbers = wordsBefore[w] == wordsAfter[w] || (is_number(wordsBefore[w]) && is_number(wordsAfter[w]));
std::string line;
if (before == after)
line = before;
else if (isNumbers)
for (size_t w = 0; w < wordsBefore.size(); w++)
line += (w > 0 ? " " : "") +
(wordsBefore[w] == wordsAfter[w] ? wordsBefore[w] : wordsBefore[w] + " > " + wordsAfter[w]);
else if (before.empty())
line = "> " + after;
else
{
line = before + " > " + after;
if (game.text.size_get(line.c_str()).x > PREVIEW_LINE_MAX)
line = before + "\n> " + after;
}
result += (i > 0 ? "\n" : "") + line;
}
SDL_strlcpy(text, result.c_str(), size);
}
// Name, numbers at a level and description of a weapon or trinket. Trinkets say how many copies are equipped, and
// when they stack.
Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int owner)
{
Tooltip tooltip;
tooltip.isTrinket = isTrinket;
tooltip.item = item;
Fighter& fighter = owner >= 0 ? game.fighters[owner] : game.local_get();
if (isTrinket)
{
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;
// In the sandbox, the effects show what the next level changes after a ">", as weapons' stats do.
bool isPreview = game.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1;
char effects[TOOLTIP_LENGTH];
if (isPreview)
effects_preview_get(game, def.effects[level], def.effects[level + 1], effects, sizeof(effects));
else
SDL_strlcpy(effects, def.effects[level], sizeof(effects));
if (isPreview)
SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d, > Level %d)\n%s\n%s", level + 1, level + 2,
effects, equipped);
else
SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), "(Level %d)\n%s\n%s", level + 1, effects, equipped);
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;
// In the sandbox, values that change at the next level show it after a ">".
bool isPreview = game.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1;
SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), isPreview ? "(Level %d, > Level %d)" : "(Level %d)", level + 1,
level + 2);
stats_table_build(fighter, item, level, isPreview, tooltip);
SDL_snprintf(tooltip.description, sizeof(tooltip.description), "\n%s\n\n%s", def.blurb, def.chargeBlurb);
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 = text.size_get(tooltip.stats, font);
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, 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;
text.draw(game.renderer, tooltip.name, {.color = color_fade(tint, alpha), .position = cursor}, font);
cursor.y += text.size_get(tooltip.name, font).y;
text.draw(game.renderer, tooltip.stats, {.color = color_fade(shade, alpha), .position = cursor}, font);
cursor.y += text.size_get(tooltip.stats, font).y;
float columns[3];
Vec2 table = table_layout(game, tooltip, font, columns);
for (int row = -1; row < tooltip.rowCount && table.y > 0; row++)
table_row_draw(game, tooltip, row, {cursor.x, cursor.y + TABLE_SPACE + (row + 1) * 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)
icon_draw(game, tooltip.isTrinket, tooltip.item,
{.color = tint,
.position = {area.x + area.w - (isScrolling ? 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 = -1; 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.
if (tooltip.item >= 0)
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, tint);
tooltip_text_draw(game, tooltip, {.position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}});
if (tooltip.item >= 0)
icon_draw(game, tooltip.isTrinket, tooltip.item,
{.color = tint,
.position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2,
panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}});
}
// Black box with a bordered, cut-cornered edge in the given color. 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)
{
chamfer_draw(renderer, rect, border);
chamfer_draw(renderer,
{rect.x + PANEL_BORDER, rect.y + PANEL_BORDER, rect.w - PANEL_BORDER * 2, rect.h - PANEL_BORDER * 2},
color_fade(BLACK, fillAlpha >= 0 ? fillAlpha : border.a));
}
// Fills a rect with its corners cut diagonally, one pixel per step.
void Ui::chamfer_draw(Renderer& renderer, Rect rect, Color color)
{
for (int step = 0; step < CHAMFER; step++)
{
float inset = CHAMFER - 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 + CHAMFER, rect.w, rect.h - CHAMFER * 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}});
}
// 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 centred, 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 box filled with a colour, framed lit while value is its option (or it's under the mouse), and clicking it picks
// that option, like a radio.
bool Ui::swatch_draw(Game& game, Rect box, Color color, 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);
}
bool isOn = value == option;
if (isOn || isMouse || isFocused)
panel_draw(game.renderer, box, widget_color(true));
float inset = isOn ? 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 (*character < GLYPH_FIRST || *character >= GLYPH_FIRST + GLYPH_COUNT)
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 centred 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 colour, its name over it and a caption under it.
void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color color, const char* caption)
{
float centre = area.x + area.w - FIGHTER_PREVIEW_WIDTH / 2;
float feet = area.y + PREVIEW_FEET;
Vec2 nameSize = game.text.size_get(name);
game.text.draw(game.renderer, name,
{.color = color, .position = {std::round(centre - nameSize.x / 2), area.y + PREVIEW_NAME_Y}});
Anm2& player = game.sprites.player;
Anm2Animation* walk = player.animation_get(PREVIEW_ANIMATION);
float frame = walk ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)walk->length) : 0;
player.draw(game.renderer, PREVIEW_ANIMATION, frame,
{.color = color, .position = {centre, feet}, .scale = {PREVIEW_SCALE, PREVIEW_SCALE}});
if (!caption || !*caption)
return;
Vec2 captionSize = game.text.size_get(caption);
game.text.draw(game.renderer, caption,
{.color = shade, .position = {std::round(centre - captionSize.x / 2), feet + PREVIEW_CAPTION_GAP}});
}
// 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_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);
float bars = PING_BARS * (PING_BAR_WIDTH + PING_BAR_GAP) - PING_BAR_GAP;
return bars + (text.empty() ? 0 : PING_BAR_GAP * 2 + game.text.size_get(text.c_str()).x);
}
float Ui::ping_draw(Game& game, Vec2 corner, float ping, float alpha)
{
int lit = 0;
if (ping >= 0)
{
lit = PING_BARS;
for (float limit : PING_LIMITS)
lit -= ping >= limit;
lit = std::max(lit, 1);
}
Color color = lit > 0 ? PING_COLORS[lit - 1] : shade;
float x = std::round(corner.x);
float bottom = std::round(corner.y + LINE_HEIGHT - 3);
for (int i = 0; i < PING_BARS; i++)
{
float height = 2 + i * 2;
game.renderer.rect_draw({x, bottom - height, PING_BAR_WIDTH, height},
color_fade(i < lit ? color : color_shade(shade, 0.5f), alpha));
x += PING_BAR_WIDTH + PING_BAR_GAP;
}
std::string text = ping_text_get(ping);
if (!text.empty())
game.text.draw(game.renderer, text.c_str(),
{.color = color_fade(color, alpha), .position = {x + PING_BAR_GAP, corner.y}});
return ping_width_get(game, ping);
}
// 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 - SCROLL_WIDTH, page.area.y, 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)
{
isMenuSeen |= game.menu.isOpen;
if (isMenuSeen)
hintAlpha = std::max(0.0f, hintAlpha - TICK / HINT_FADE);
spectate_update(game);
// The HUD's hotbar slots and the other fighters' windows brighten while the mouse is over them, and dim again
// 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 (int kind = 0; kind < 2; kind++)
{
int count = kind == 1 ? local.trinketSlots : local.weaponSlots;
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 == 1, 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]));
}
// 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 (one bar for all copies). A bar keeps its item's
// icon while it fades out after the item goes.
Fighter& fighter = game.local_get();
float gloves = fighter.trinket_get(TRINKET_GLOVES, 1);
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 &&
std::find(fighter.trinketLoadout, fighter.trinketLoadout + slot, trinket) == fighter.trinketLoadout + slot)
{
left = fighter.trinket_cooldown_get(trinket);
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, each on its own bar two slots long (whatever the hotbar's size) with its icon.
// Every bar cooling down, a row each: weapons downward from under the weapon hotbar, trinkets upward from over the
// trinket hotbar, each starting far enough off its hotbar that the icons (taller than the bars) clear its slots. Past a
// handful of one kind at once, its rows tighten and their icons shrink to half size so a full loadout still fits.
void Ui::cooldowns_draw(Game& game)
{
float width = COOLDOWN_SLOTS * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP;
for (int kind = 0; kind < 2; kind++)
{
bool isTrinket = kind == 1;
CooldownBar* bars = isTrinket ? trinketBars : weaponBars;
int showing = 0;
for (int slot = 0; slot < SLOT_MAX; slot++)
showing += bars[slot].fade > 0;
bool isCompact = showing > COOLDOWN_FULL_ROWS;
float spacing = isCompact ? COOLDOWN_COMPACT_SPACING : COOLDOWN_SPACING;
float iconScale = isCompact ? MINI_ICON_SCALE : 1;
float clearance = std::max(0.0f, (ICON_SIZE * iconScale - COOLDOWN_BAR_HEIGHT) / 2);
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);
float y = isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - SLOT_SIZE - COOLDOWN_GAP - COOLDOWN_BAR_HEIGHT -
std::ceil(clearance) - step
: HOTBAR_MARGIN + SLOT_SIZE + COOLDOWN_GAP + std::ceil(clearance) + step;
cooldown_bar_draw(game, {HOTBAR_MARGIN, y, width, COOLDOWN_BAR_HEIGHT}, bar, isTrinket, bar.item,
iconScale);
}
}
}
// A bordered bar draining with the cooldown and its item's icon just right of it, at full size so its pixels stay
// whole. Both fade with the bar.
void Ui::cooldown_bar_draw(Game& game, Rect rect, CooldownBar& bar, bool isTrinket, int item, float iconScale)
{
Color color = color_fade(tint, bar.fade);
bar_draw(game.renderer, rect, bar.fill, color);
float icon = ICON_SIZE * iconScale;
icon_draw(game, isTrinket, item,
{.color = color,
.position = {std::round(rect.x + rect.w + COOLDOWN_GAP + icon / 2), rect.y + rect.h / 2},
.scale = {iconScale, iconScale}});
}
// A weapon's or trinket's HUD icon, centred on the placement.
void Ui::icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSize size)
{
Sprites& sprites = game.sprites;
Anm2* sheets[2][ICON_SIZE_COUNT] = {{&sprites.icons, &sprites.iconsSmall, &sprites.iconsTiny},
{&sprites.trinkets, &sprites.trinketsSmall, &sprites.trinketsTiny}};
Anm2& art = *sheets[isTrinket][size];
art.draw(game.renderer, isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, 0, placement);
}
// An arrow at the screen's edge for every other living fighter out of view, pointing toward it, in its colour, 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.fighterCount; i++)
{
Fighter& fighter = game.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 colour: 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.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);
Vec2 aim = isMouse ? input.mouse : game.fighters[i].controls.cursor - camera;
game.sprites.ui.draw(game.renderer, "Crosshair", 0,
{.color = game.color_get(i), .position = {std::floor(aim.x), std::floor(aim.y)}});
}
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 colour: 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.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.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.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.match.health_get(), color);
}
int fit = (panel.w - PICK_PADDING * 2 + MINI_STEP - MINI_SLOT) / MINI_STEP;
for (int kind = 0; kind < 2; kind++)
{
bool isTrinket = kind == 1;
int slots = std::min(fit, isTrinket ? fighter.trinketSlots : fighter.weaponSlots);
float rowY = y + LINE_HEIGHT + kind * MINI_STEP;
for (int slot = 0; slot < slots; slot++)
{
int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[slot];
bool isLit = isTrinket ? 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, isTrinket, 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.match.state;
// Watching a queued game from the lobby, it's whoever's still in it.
if (game.session.is_watching())
{
for (int i = 0; i < game.fighterCount; i++)
if (game.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.fighterCount; i++)
if (i != game.local && game.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();
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.fighterCount && (fighter != game.local || isWatching) &&
game.fighters[fighter].isAlive;
};
auto next_get = [&](int from, int step) {
for (int k = 1; k <= game.fighterCount; k++)
{
int fighter = ((from + step * k) % game.fighterCount + game.fighterCount) % game.fighterCount;
if (is_watchable(fighter))
return fighter;
}
return from;
};
if (!is_watchable(spectated))
{
int taker = spectated >= 0 ? game.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 colour, near the bottom, with the controls that switch.
void Ui::spectate_draw(Game& game)
{
if (spectated < 0)
return;
Input& input = game.input;
bool isPad = input.devices[DEVICE_KEYBOARD].isAiming;
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, true));
char keys[2][TOOLTIP_LENGTH];
for (int i = 0; i < 2; i++)
{
Binding binding = input.bindings[i == 0 ? CONTROL_LEFT : CONTROL_RIGHT];
input.binding_name_get(isPad ? binding.pad : binding.key, isPad, keys[i], sizeof(keys[i]));
}
SDL_snprintf(text, sizeof(text), "%s / %s: Switch%s", keys[0], keys[1],
game.session.is_watching() ? " Esc: Back to Lobby" : "");
text_center_draw(game, text, y + LINE_HEIGHT, 1, shade);
}
// Lays out the other fighters' windows in a row along the bottom right, never left of the screen's middle (nor over the
// local hotbar and cooldowns). A window is as wide as its icon row, but no wider than one holding two weapons and two
// trinkets at full size: past that, or when the row doesn't fit, icons shrink to small, then tiny. Each kind wraps onto
// more rows past three across, and tiny icons wrap further if they still don't fit. Names and health use the small
// font, or the tiny one for every window if any doesn't fit.
RivalLayout Ui::rivals_layout(Game& game)
{
RivalLayout layout;
Fighter& local = game.local_get();
int slots[2] = {};
for (int i = 0; i < game.fighterCount; i++)
{
if (i == game.local && !game.session.is_watching())
continue;
layout.fighters[layout.count++] = i;
slots[0] = std::max(slots[0], game.fighters[i].weaponSlots);
slots[1] = std::max(slots[1], game.fighters[i].trinketSlots);
}
if (layout.count == 0)
return layout;
float step = SLOT_SIZE + SLOT_GAP;
float left =
std::max(CANVAS_WIDTH / 2.0f,
HOTBAR_MARGIN + std::max(local.trinketSlots * step, COOLDOWN_SLOTS * step + ICON_SIZE) + RIVAL_GAP);
float room = CANVAS_WIDTH - HOTBAR_MARGIN - left;
float inset = PANEL_BORDER + RIVAL_PADDING;
auto icons_width = [&](int size, int columns) {
return columns * (ICON_PIXELS[size] + RIVAL_ICON_GAP) + RIVAL_DIVIDER - RIVAL_ICON_GAP;
};
float widthMax = icons_width(ICON_FULL, RIVAL_FULL_SLOTS) + inset * 2;
char health[TOOLTIP_LENGTH];
SDL_snprintf(health, sizeof(health), "%d", (int)std::ceil(game.match.health_get()));
float minimum = game.text.size_get(health, FONT_TINY).x + RIVAL_PADDING + RIVAL_BAR_MIN + inset * 2;
float width = 0;
for (int size = ICON_FULL; size < ICON_SIZE_COUNT; size++)
{
// 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.
int rowsMin = (std::max(slots[0], slots[1]) + RIVAL_COLUMNS_MAX - 1) / RIVAL_COLUMNS_MAX;
int rowsMax = size == ICON_TINY ? SLOT_MAX : rowsMin;
for (int rows = std::max(1, rowsMin); rows <= rowsMax; rows++)
{
for (int kind = 0; kind < 2; kind++)
layout.columns[kind] = (slots[kind] + rows - 1) / rows;
float icons = icons_width(size, layout.columns[0] + layout.columns[1]) + inset * 2;
width = std::ceil(std::max(icons, minimum));
layout.size = (IconSize)size;
layout.rows = rows;
bool isFit = icons <= widthMax && layout.count * width + (layout.count - 1) * RIVAL_GAP <= room;
if (isFit)
size = ICON_SIZE_COUNT;
if (size == ICON_SIZE_COUNT)
break;
}
}
float inner = width - inset * 2;
layout.font = game.match.is_racing() || game.text.size_get(health).x + RIVAL_PADDING + RIVAL_BAR_MIN <= inner
? FONT_SMALL
: FONT_TINY;
for (int k = 0; k < layout.count; k++)
if (game.text.size_get(name_get(game, layout.fighters[k])).x > inner)
layout.font = FONT_TINY;
layout.line = game.text.line_height_get(layout.font);
float healthHeight = game.match.is_racing() ? 0 : layout.line + RIVAL_PADDING;
float icons = layout.rows * (ICON_PIXELS[layout.size] + RIVAL_ICON_GAP) - RIVAL_ICON_GAP;
float height = inset + layout.line + RIVAL_PADDING + healthHeight + icons + inset;
float x = CANVAS_WIDTH - HOTBAR_MARGIN - layout.count * width - (layout.count - 1) * RIVAL_GAP;
for (int k = 0; k < layout.count; k++)
layout.cards[k] = {std::round(x + k * (width + RIVAL_GAP)), CANVAS_HEIGHT - HOTBAR_MARGIN - height, width,
height};
return layout;
}
// Every other fighter's window in a match, bordered in its colour: its name centred, its health with a bar beside it,
// then its weapons, a divider, and its trinkets, the weapon in hand lit. A window is dimmed (a fighter out of the round
// more so) until the mouse comes over it, when it eases to full brightness. Hovering an
// icon shows the item's tooltip, as that fighter has it, in its colour.
void Ui::rivals_draw(Game& game)
{
Match& match = game.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;
int hoveredFighter = -1;
int hoveredKind = 0;
int hoveredSlot = 0;
for (int k = 0; k < layout.count; k++)
{
int index = layout.fighters[k];
Fighter& fighter = game.fighters[index];
Rect card = layout.cards[k];
float idle = fighter.isAlive || match.is_racing() ? HUD_IDLE_ALPHA : RIVAL_OUT_ALPHA;
float alpha = index == spectated ? 1 : idle + (1 - idle) * rivalHovers[index];
// Faded by darkening toward the solid black fill, so the window stays opaque and nothing in it shows through
// anything else.
Color color = color_shade(game.color_get(index), alpha);
Color dim = color_shade(color, SECONDARY_SHADE);
panel_draw(renderer, card, color, 1);
float y = card.y + inset;
Font font = layout.font;
const char* name = name_get(game, index);
Vec2 size = game.text.size_get(name, font);
game.text.draw(
renderer, name,
{.color = color,
.position = {std::round(card.x + (card.w - size.x) / 2), std::round(y + (layout.line - size.y) / 2)}},
font);
y += layout.line + RIVAL_PADDING;
if (!match.is_racing())
{
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;
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), card.x + card.w - inset - x, barHeight};
bar_draw(renderer, bar, std::clamp(fighter.health / match.health_get(), 0.0f, 1.0f), color);
y += layout.line + RIVAL_PADDING;
}
// Weapons then trinkets, each in its columns, centred with the divider between.
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 (int kind = 0; kind < 2; kind++)
{
bool isTrinket = kind == 1;
int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots;
int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout;
for (int slot = 0; slot < slots; slot++)
{
Rect box = {x + slot % layout.columns[kind] * cell, y + slot / layout.columns[kind] * cell, iconSize,
iconSize};
int item = loadout[slot];
if (item < 0)
continue;
bool isLit = isTrinket || slot == fighter.slot;
icon_draw(game, isTrinket, item,
{.color = isLit ? color : dim, .position = {box.x + box.w / 2, box.y + box.h / 2}},
layout.size);
if (is_point_in_rect(game.input.mouse, box))
{
hoveredFighter = index;
hoveredKind = kind;
hoveredSlot = slot;
}
}
x += layout.columns[kind] * cell;
if (kind == 0)
{
renderer.rect_draw({x, y, RIVAL_DIVIDER, layout.rows * cell - RIVAL_ICON_GAP}, dim);
x += RIVAL_DIVIDER + RIVAL_ICON_GAP;
}
}
}
if (hoveredFighter < 0 || game.menu.isOpen)
return;
Fighter& fighter = game.fighters[hoveredFighter];
bool isTrinket = hoveredKind == 1;
int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[hoveredSlot];
int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[hoveredSlot];
Tooltip tooltip = tooltip_build(game, isTrinket, item, level, hoveredFighter);
// The tooltip takes that fighter's colour for the moment, so it's clear whose item it is.
Color colors[2] = {tint, shade};
tint = game.color_get(hoveredFighter);
shade = color_shade(tint, SECONDARY_SHADE);
tooltip_draw(game, tooltip);
tint = colors[0];
shade = colors[1];
}
// 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.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 colour: 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.match;
Input& input = game.input;
Fighter& fighter = game.fighters[index];
int device = game.devices[index];
bool isKeyboard = device == DEVICE_KEYBOARD;
bool& isTrinket = isPickTrinkets[index];
isTrinket = match.isTrinketPick && (isTrinket || !match.isWeaponPick);
bool isBoth = match.isWeaponPick && match.isTrinketPick;
if (isBoth && (input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_LEFT_SHOULDER) ||
input.is_pad_pressed(device, SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER)))
isTrinket = !isTrinket;
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 = isTrinket ? TRINKET_COUNT : WEAPON_COUNT;
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 = isTrinket ? fighter.trinketSlots : fighter.weaponSlots;
int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout;
float x = panel.x + PICK_PADDING;
float y = panel.y + PICK_PADDING;
char text[TOOLTIP_LENGTH];
const char* kind =
isBoth ? (isTrinket ? "Trinkets (Bumpers)" : "Weapons (Bumpers)") : (isTrinket ? "Trinkets" : "Weapons");
const char* item = isTrinket ? TRINKET_DEFS[cursor].name : WEAPON_DEFS[cursor].name;
SDL_snprintf(text, sizeof(text), "%s - %s: %s", name_get(game, index), kind, 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, isTrinket, std::min(empty, slots - 1), i);
}
}
slot_draw(game,
{.rect = box, .item = i, .isHoverable = isKeyboard, .isLit = i == cursor, .isTrinket = isTrinket});
}
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, isTrinket, slot, cursor);
if (isHovered && input.isRightClicked)
pick_send(game, index, isTrinket, slot, -1);
slot_draw(game, {.rect = box,
.item = loadout[slot],
.label = slot + 1,
.isHoverable = isKeyboard,
.isLit = loadout[slot] >= 0,
.isTrinket = isTrinket});
}
if (isKeyboard && input.slotPressed >= 0 && input.slotPressed < slots)
pick_send(game, index, isTrinket, 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, isTrinket, 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, isTrinket, 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, bool isTrinket, int slot, int item)
{
game.input.edit_send(game.devices[fighter], pick_encode(isTrinket, 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 centre = 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) ? centre < -1 : centre > 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 centre = {focusRect.x + focusRect.w / 2, focusRect.y + focusRect.h / 2};
if (!scrollWant || !isFocusShown || !is_point_in_rect(centre, 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 colour (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.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.fighterCount; q++)
{
Queue& queue = match.queues[q];
if (!queue.isActive || match.isAway[q])
continue;
Color color = game.color_get(q, true);
Rect area = queue.area;
int members[MAX_FIGHTERS];
int count = match.queue_members_get(game, 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.fighters[q];
Rect box = queuer.body.box;
Vec2 icon = {std::round(box.x + box.w / 2), std::round(box.y - 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;
bool isPad = input.devices[DEVICE_KEYBOARD].isAiming;
Binding binding = input.bindings[CONTROL_READY];
char key[TOOLTIP_LENGTH];
input.binding_name_get(isPad ? binding.pad : binding.key, isPad, 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.fighterCount || game.menu.isOpen)
return;
const Rules& rules = game.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, true));
game_summary_draw(game, rules, {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING});
}