- Readying in a countdown deals any empty slots at random right away, and the countdown reads "Starting..." once everyone is ready. - /pvp toggles PvP in the sandbox, offline or in a lobby. - Rival windows sit at half opacity (half again when knocked out, and when hovered), show no item tooltips, and drop to small icons past two full-size rows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2858 lines
129 KiB
C++
2858 lines
129 KiB
C++
#include "ui.h"
|
|
#include "game.h"
|
|
#include <SDL3/SDL.h>
|
|
#include <algorithm>
|
|
|
|
constexpr float BANNER_SCALE = 4;
|
|
constexpr float BANNER_Y = 110;
|
|
constexpr float BAR_GAP = 2;
|
|
constexpr float LOADOUT_FADE = 0.25f;
|
|
constexpr float LOADOUT_GAP = 4;
|
|
constexpr float LOADOUT_DIVIDER = 2;
|
|
constexpr float ICON_SIZE = 24;
|
|
constexpr float RIVAL_BAR_MIN = 16;
|
|
constexpr float RIVAL_DIVIDER = 1;
|
|
constexpr float RIVAL_GAP = 4;
|
|
constexpr float RIVAL_ICON_GAP = 2;
|
|
constexpr float RIVAL_ALPHA = 0.5f;
|
|
constexpr float RIVAL_OUT_SHARE = 0.5f;
|
|
constexpr float SPECTATE_GAP = 4;
|
|
constexpr Control SPECTATE_CONTROLS[] = {CONTROL_LEFT, CONTROL_RIGHT, CONTROL_ATTACK};
|
|
constexpr float HUD_IDLE_ALPHA = 0.5f;
|
|
constexpr float HUD_HOVER_ALPHA = 0.5f;
|
|
constexpr float HUD_HOVER_FADE = 0.15f;
|
|
constexpr float RIVAL_PADDING = 2;
|
|
constexpr int RIVAL_COLUMNS_MAX = 3;
|
|
// What the other fighters' windows try, from largest to smallest, until they all fit in a couple of rows: shrinking
|
|
// icons before the font, then leaving the icons out.
|
|
constexpr int RIVAL_ROWS_MAX = 2;
|
|
// The most rows of full-size icons the windows can stack up between them before the icons shrink.
|
|
constexpr int RIVAL_FULL_ICON_ROWS_MAX = 2;
|
|
constexpr int RIVAL_STYLES = 6;
|
|
constexpr int RIVAL_ICON_STYLES = 4;
|
|
constexpr Font RIVAL_FONTS[RIVAL_STYLES] = {FONT_SMALL, FONT_SMALL, FONT_TINY, FONT_TINY, FONT_SMALL, FONT_TINY};
|
|
constexpr IconSize RIVAL_SIZES[RIVAL_ICON_STYLES] = {ICON_FULL, ICON_SMALL, ICON_SMALL, ICON_TINY};
|
|
constexpr float COOLDOWN_SLOTS = 2;
|
|
constexpr float MINI_ICON_SCALE = 0.5f;
|
|
constexpr float MINI_SLOT = 16;
|
|
constexpr float MINI_STEP = 18;
|
|
constexpr float PICK_PADDING = 4;
|
|
constexpr float PLAYER_PANEL_HEIGHT = 56;
|
|
constexpr float READY_BUTTON_HEIGHT = 18;
|
|
constexpr float READY_BUTTON_WIDTH = 80;
|
|
constexpr float READY_GAP = 2;
|
|
constexpr float TIP_GAP = 4;
|
|
constexpr float COUNTDOWN_Y = 40;
|
|
constexpr float COUNTDOWN_DIM = 0.6f;
|
|
constexpr const char* ICON_SUFFIXES[ICON_SIZE_COUNT] = {"", " Small", " Tiny"};
|
|
constexpr const char* CHEVRONS[ICON_SIZE_COUNT] = {"Chevron", "Chevron Small", "Chevron Tiny"};
|
|
constexpr float CHEVRON_INSETS[ICON_SIZE_COUNT] = {3, 2, 2};
|
|
constexpr float CHEVRON_STEPS[ICON_SIZE_COUNT] = {3, 2, 2};
|
|
constexpr float CHARGE_BAR_HEIGHT = 8;
|
|
constexpr float CHARGE_BAR_WIDTH = 20;
|
|
constexpr float DPS_SCALE = 2;
|
|
constexpr float FIGHT_BANNER_TIME = 0.6f;
|
|
constexpr float HEALTH_BAR_HEIGHT = 7;
|
|
constexpr float HEALTH_BAR_WIDTH = 28;
|
|
constexpr float ROUND_SCALE = 3;
|
|
constexpr float ASIDE_LABEL_SCALE = 2;
|
|
constexpr float ASIDE_PADDING = 4;
|
|
constexpr float SCORE_SCALE = 2;
|
|
constexpr float SCORE_HOTBAR_GAP = 8;
|
|
constexpr float STATUS_BAR_HEIGHT = 12;
|
|
constexpr float STATUS_BAR_WIDTH = 80;
|
|
constexpr float STATUS_GAP = 6;
|
|
// How thick a slot's (and its cooldown bar's) edge is at each size.
|
|
constexpr float SLOT_STROKES[ICON_SIZE_COUNT] = {PANEL_BORDER, PANEL_BORDER, 1};
|
|
constexpr float HOVER_BORDER = 1;
|
|
constexpr float HOVER_GAP = 1;
|
|
constexpr float TARGET_GAP = 1;
|
|
constexpr float TARGET_HEIGHT = 2;
|
|
constexpr float TARGET_INSET = 4;
|
|
constexpr float CARET_BLINK = 0.5f;
|
|
constexpr float COOLDOWN_BAR_HEIGHTS[ICON_SIZE_COUNT] = {7, 6, 6};
|
|
constexpr float COOLDOWN_FADE_IN = 0.1f;
|
|
constexpr float COOLDOWN_FADE_OUT = 0.25f;
|
|
constexpr float COOLDOWN_GAP = 4;
|
|
constexpr float COOLDOWN_ROW_GAP = 2;
|
|
constexpr float COOLDOWN_SLIDE = 12;
|
|
constexpr float 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[] = "Preview";
|
|
constexpr float SUMMARY_ICON_GAP = 4;
|
|
constexpr float NAME_PING_GAP = 3;
|
|
// A queued game's area: its fill and edge, the queuer's icon over it (how high it floats and bobs, and how fast), and
|
|
// the gap to the lines under it.
|
|
constexpr float QUEUE_FILL_ALPHA = 0.12f;
|
|
constexpr float QUEUE_EDGE_ALPHA = 0.6f;
|
|
constexpr float QUEUE_ICON_RISE = 44;
|
|
constexpr float QUEUE_ICON_BOB = 2;
|
|
constexpr float QUEUE_ICON_SPEED = 3;
|
|
constexpr float QUEUE_TEXT_GAP = 3;
|
|
constexpr float FIELD_INSET = 4;
|
|
constexpr float SLIDER_INSET = 3;
|
|
constexpr float TEXT_INSET = (WIDGET_HEIGHT - LINE_HEIGHT) / 2;
|
|
constexpr float HINT_FADE = 0.5f;
|
|
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;
|
|
// Slot numbers: their font and corner at each size (none on tiny slots).
|
|
constexpr Font LABEL_FONTS[ICON_TINY] = {FONT_SMALL, FONT_TINY};
|
|
constexpr Vec2 LABEL_OFFSETS[ICON_TINY] = {{4, 2}, {3, 2}};
|
|
// Where a full slot's number sits from its icon's corner.
|
|
constexpr Vec2 ICON_LABEL_OFFSET = {LABEL_OFFSETS[ICON_FULL].x - (SLOT_SIZE - ICON_SIZE) / 2,
|
|
LABEL_OFFSETS[ICON_FULL].y - (SLOT_SIZE - ICON_SIZE) / 2};
|
|
|
|
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);
|
|
|
|
// How opaque a HUD element (other than a hotbar slot) is, fading as the mouse comes over it.
|
|
static float hover_alpha_get(float hover)
|
|
{
|
|
return 1 - (1 - HUD_HOVER_ALPHA) * hover;
|
|
}
|
|
|
|
void Ui::init(Game& game)
|
|
{
|
|
for (int i = 0; i < MAX_FIGHTERS; i++)
|
|
{
|
|
SDL_snprintf(botNames[i], sizeof(botNames[i]), "Bot %d", i + 1);
|
|
SDL_snprintf(playerNames[i], sizeof(playerNames[i]), "Player %d", i + 1);
|
|
}
|
|
}
|
|
|
|
// The name shown for a fighter: the screen name for this screen's own, and "Bot" with its number for the rest.
|
|
const char* Ui::name_get(Game& game, int fighter)
|
|
{
|
|
// Online, a player's name, numbered when someone earlier in the game has the same one ("Fighter", "Fighter (2)").
|
|
if (game.session.isActive && game.sim.match.owners[fighter] >= 0)
|
|
{
|
|
std::string name = game.session.base_name_get(game, game.sim.match.owners[fighter]);
|
|
int same = 1;
|
|
for (int i = 0; i < fighter; i++)
|
|
same += game.sim.match.owners[i] >= 0 && game.session.base_name_get(game, game.sim.match.owners[i]) == name;
|
|
if (same > 1)
|
|
name += " (" + std::to_string(same) + ")";
|
|
SDL_strlcpy(onlineNames[fighter], name.c_str(), sizeof(onlineNames[fighter]));
|
|
return onlineNames[fighter];
|
|
}
|
|
if (fighter == game.local)
|
|
return game.settings.name;
|
|
return game.is_local(fighter) ? playerNames[fighter] : botNames[fighter];
|
|
}
|
|
|
|
void Ui::draw(Game& game)
|
|
{
|
|
// Watching a queued game, nothing of it is this screen's own: the HUD is white, and there's no hotbar.
|
|
bool isWatching = game.session.is_watching();
|
|
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.
|
|
statusRect = {};
|
|
queues_draw(game);
|
|
bars_draw(game);
|
|
game.chat.overhead_draw(game);
|
|
Vec2 camera = game.renderer.camera;
|
|
game.renderer.camera = {};
|
|
offscreen_draw(game, camera);
|
|
bool isBarsRaised = game.menu.is_bars_open();
|
|
bool isShared = game.sim.match.state != MATCH_PRACTICE && game.local_count() > 1;
|
|
if (!isBarsRaised && !isShared && !isWatching)
|
|
{
|
|
cooldowns_draw(game);
|
|
hotbar_draw(game);
|
|
}
|
|
if (isShared)
|
|
panels_draw(game);
|
|
else if (!isWatching)
|
|
status_draw(game);
|
|
match_draw(game);
|
|
if (!isShared)
|
|
{
|
|
rivals_draw(game);
|
|
spectate_draw(game);
|
|
}
|
|
game.chat.draw(game);
|
|
game.menu.draw(game);
|
|
if (game.sim.match.state == MATCH_COUNTDOWN && game.menu.isOpen && !isShared)
|
|
countdown_aside_draw(game, 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;
|
|
}
|
|
|
|
// 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.sim.match;
|
|
Renderer& renderer = game.renderer;
|
|
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = game.sim.fighters[i];
|
|
if (!fighter.isAlive)
|
|
continue;
|
|
Rect box = fighter.body.box;
|
|
float centerX = box.x + box.w / 2;
|
|
float bottom = std::round(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.sim.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 || game.scene.is_flashing(fighter);
|
|
Rect bar = {std::round(centerX - CHARGE_BAR_WIDTH / 2), bottom - CHARGE_BAR_HEIGHT, CHARGE_BAR_WIDTH,
|
|
CHARGE_BAR_HEIGHT};
|
|
bar_draw(renderer, bar, isShown ? fill : 0, color);
|
|
}
|
|
}
|
|
|
|
// A bar, its edge stroke thick, filled from the left by fill, from 0 to 1.
|
|
void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color, float stroke)
|
|
{
|
|
panel_draw(renderer, bar, color, -1, stroke);
|
|
renderer.rect_draw({bar.x + stroke, bar.y + stroke, std::round((bar.w - stroke * 2) * fill), bar.h - stroke * 2},
|
|
color);
|
|
}
|
|
|
|
// In practice, 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.sim.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.sim.fighterCount; i++)
|
|
length += SDL_snprintf(text + length, sizeof(text) - length, i > 0 ? " - %d" : "%d", match.wins[i]);
|
|
Vec2 scale = {SCORE_SCALE, SCORE_SCALE};
|
|
float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x;
|
|
int weapons = game.local_get().weaponSlots;
|
|
Rect last = game.menu.slot_rect(false, weapons - 1, weapons);
|
|
float hotbarRight = last.x + last.w;
|
|
float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP));
|
|
float center = x + scoreWidth / 2;
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
{
|
|
char piece[TOOLTIP_LENGTH];
|
|
if (i > 0)
|
|
{
|
|
game.text.draw(renderer, " - ", {.color = WHITE, .position = {x, HOTBAR_MARGIN}, .scale = scale});
|
|
x += game.text.size_get(" - ", FONT_SMALL, SCORE_SCALE).x;
|
|
}
|
|
SDL_snprintf(piece, sizeof(piece), "%d", match.wins[i]);
|
|
game.text.draw(renderer, piece,
|
|
{.color = game.color_get(i), .position = {x, HOTBAR_MARGIN}, .scale = scale});
|
|
x += game.text.size_get(piece, FONT_SMALL, SCORE_SCALE).x;
|
|
}
|
|
|
|
// The round's clock, in white: counting down to the time limit, or up when there's none.
|
|
float limit = match.time_limit_get();
|
|
int seconds = std::ceil(limit > 0 ? std::max(0.0f, limit - match.clock) : match.clock);
|
|
SDL_snprintf(text, sizeof(text), "%d:%02d", seconds / (int)SECONDS_PER_MINUTE,
|
|
seconds % (int)SECONDS_PER_MINUTE);
|
|
float width = game.text.size_get(text).x;
|
|
game.text.draw(renderer, text,
|
|
{.color = WHITE,
|
|
.position = {std::round(center - width / 2),
|
|
HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE)}});
|
|
}
|
|
|
|
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 %s", info.round, info.time);
|
|
text_center_draw(game, text, y, ROUND_SCALE);
|
|
y += game.text.line_height_get(FONT_SMALL, ROUND_SCALE) + READY_GAP;
|
|
SDL_snprintf(text, sizeof(text), isLocalReady ? "%d/%d Ready" : "%d/%d Ready - Press %s to Ready Up",
|
|
info.ready, game.sim.fighterCount, info.key);
|
|
text_center_draw(game, text, y, 1);
|
|
y += LINE_HEIGHT + READY_GAP;
|
|
if (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.sim.fighterCount; i++)
|
|
{
|
|
if (match.owners[i] < 0 || match.isGone[i])
|
|
continue;
|
|
SDL_snprintf(text, sizeof(text), "- %s%s", name_get(game, i),
|
|
match.againTicks > 0 ? " [Starting!]"
|
|
: match.isReady[i] ? " [Play Again]"
|
|
: "");
|
|
text_center_draw(game, text, y, 1, game.color_get(i, 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.sim.match.clock);
|
|
text_center_draw(game, text, HOTBAR_MARGIN, SCORE_SCALE);
|
|
|
|
float left = std::round((CANVAS_WIDTH - TRACK_WIDTH) / 2);
|
|
float y = HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE) + TRACK_DOT;
|
|
game.renderer.rect_draw({left, y - TRACK_HEIGHT / 2, TRACK_WIDTH, TRACK_HEIGHT}, shade);
|
|
float length = game.sim.world.size_get().x;
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = game.sim.fighters[i];
|
|
float progress =
|
|
game.sim.match.finishTimes[i] >= 0 ? 1 : std::clamp(fighter.center_get().x / length, 0.0f, 1.0f);
|
|
float x = std::round(left + progress * TRACK_WIDTH - TRACK_DOT / 2);
|
|
Rect dot = {x, std::round(y - TRACK_DOT / 2), TRACK_DOT, TRACK_DOT};
|
|
game.renderer.rect_draw(dot, game.color_get(i));
|
|
}
|
|
}
|
|
|
|
// The race's finishing order from y down, one line per racer in its colour, with its time (or DNF). Returns the y
|
|
// below the last line.
|
|
float Ui::results_draw(Game& game, float y)
|
|
{
|
|
Match& match = game.sim.match;
|
|
int order[MAX_FIGHTERS];
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
order[i] = i;
|
|
auto rank = [&](int fighter) {
|
|
return match.finishTimes[fighter] >= 0 ? match.finishTimes[fighter] : INFINITY;
|
|
};
|
|
std::stable_sort(order, order + game.sim.fighterCount, [&](int a, int b) { return rank(a) < rank(b); });
|
|
|
|
for (int place = 0; place < game.sim.fighterCount; place++)
|
|
{
|
|
int fighter = order[place];
|
|
char time[TOOLTIP_LENGTH];
|
|
char text[TOOLTIP_LENGTH];
|
|
if (match.finishTimes[fighter] >= 0)
|
|
time_format(time, sizeof(time), match.finishTimes[fighter]);
|
|
else
|
|
SDL_strlcpy(time, "DNF", sizeof(time));
|
|
SDL_snprintf(text, sizeof(text), "%d. %s %s", place + 1, name_get(game, fighter), time);
|
|
float width = game.text.size_get(text).x;
|
|
game.text.draw(game.renderer, text,
|
|
{.color = game.color_get(fighter), .position = {std::round((CANVAS_WIDTH - width) / 2), y}});
|
|
y += LINE_HEIGHT;
|
|
}
|
|
return y;
|
|
}
|
|
|
|
// A 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.slot = i;
|
|
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);
|
|
tooltip_icon_draw(game, layout.tooltips[kind][i],
|
|
{.color = color,
|
|
.position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2,
|
|
panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Text centred across the canvas with its top at y.
|
|
// What the countdown shows: the round's name, whole seconds left (or Starting... once everyone's ready), how many are
|
|
// ready, and the key that readies up.
|
|
static CountdownInfo countdown_info_get(Game& game, bool isPad)
|
|
{
|
|
Match& match = game.sim.match;
|
|
CountdownInfo info;
|
|
if (match.rules.mode == MODE_RACE)
|
|
SDL_strlcpy(info.round, "Race", sizeof(info.round));
|
|
else if (match.is_final_round(game.sim))
|
|
SDL_strlcpy(info.round, "Final Round", sizeof(info.round));
|
|
else
|
|
SDL_snprintf(info.round, sizeof(info.round), "Round %d", match.round + 1);
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
info.ready += match.isReady[i];
|
|
if (info.ready == game.sim.fighterCount)
|
|
SDL_strlcpy(info.time, "Starting...", sizeof(info.time));
|
|
else
|
|
SDL_snprintf(info.time, sizeof(info.time), "%d", (int)std::ceil(match.timer));
|
|
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 ready = info.ready;
|
|
const char* key = game.sim.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);
|
|
};
|
|
|
|
// Each line at the largest size that fits the margin.
|
|
auto scale_fit = [&](const char* line, float scale) {
|
|
for (; scale > 1; scale--)
|
|
if (game.text.size_get(line, FONT_SMALL, scale).x <= margin - ASIDE_PADDING * 2)
|
|
break;
|
|
return scale;
|
|
};
|
|
float labelScale = scale_fit(round, ASIDE_LABEL_SCALE);
|
|
float timeScale = scale_fit(info.time, ROUND_SCALE);
|
|
float height = game.text.line_height_get(FONT_SMALL, labelScale) + game.text.line_height_get(FONT_SMALL, timeScale);
|
|
float y = middle - height / 2;
|
|
y += line_draw(round, margin / 2, y, labelScale);
|
|
line_draw(info.time, margin / 2, y, timeScale);
|
|
char text[TOOLTIP_LENGTH];
|
|
|
|
float rightX = CANVAS_WIDTH - margin / 2;
|
|
y = middle - (key ? LINE_HEIGHT * 3 : LINE_HEIGHT) / 2;
|
|
SDL_snprintf(text, sizeof(text), "%d/%d Ready", ready, game.sim.fighterCount);
|
|
y += line_draw(text, rightX, y, 1);
|
|
if (key)
|
|
{
|
|
SDL_snprintf(text, sizeof(text), "Press %s", key);
|
|
y += line_draw(text, rightX, y, 1);
|
|
line_draw("to Ready Up", rightX, y, 1);
|
|
}
|
|
|
|
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 left = right;
|
|
float y = HOTBAR_MARGIN;
|
|
float rowHeight = game.text.line_height_get(FONT_SMALL, DPS_SCALE);
|
|
game.renderer.layer_begin();
|
|
|
|
if (game.sim.match.state != MATCH_PRACTICE && !game.sim.match.is_racing())
|
|
{
|
|
Fighter& fighter = game.local_get();
|
|
float maxHealth = game.sim.match.health_get();
|
|
Rect bar = {right - STATUS_BAR_WIDTH, std::round(y + (rowHeight - STATUS_BAR_HEIGHT) / 2), STATUS_BAR_WIDTH,
|
|
STATUS_BAR_HEIGHT};
|
|
bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / maxHealth, tint);
|
|
SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health)));
|
|
float width = game.text.size_get(text, FONT_SMALL, DPS_SCALE).x;
|
|
left = std::round(bar.x - STATUS_GAP - width);
|
|
game.text.draw(game.renderer, text, {.color = tint, .position = {left, y}, .scale = {DPS_SCALE, DPS_SCALE}});
|
|
y += rowHeight;
|
|
}
|
|
|
|
// The DPS and speed meters each show only if they're on in the settings.
|
|
auto meter_draw = [&](const char* label) {
|
|
float x = std::round(right - game.text.size_get(label, FONT_SMALL, DPS_SCALE).x);
|
|
left = std::min(left, x);
|
|
game.text.draw(game.renderer, label, {.color = tint, .position = {x, 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);
|
|
}
|
|
statusRect = {left, HOTBAR_MARGIN, right - left, y - HOTBAR_MARGIN};
|
|
game.renderer.layer_end(hover_alpha_get(statusHover));
|
|
}
|
|
|
|
// The weapon bar (lit on the selected slot) and the trinket bar (lit where filled). The menu decides where
|
|
// they sit, and how large. Hovering a filled slot outside the menu shows its tooltip.
|
|
void Ui::hotbar_draw(Game& game)
|
|
{
|
|
Vec2 mouse = game.input.mouse;
|
|
Fighter& fighter = game.local_get();
|
|
Slot hovered;
|
|
|
|
for (int i = 0; i < fighter.weaponSlots + fighter.trinketSlots; i++)
|
|
{
|
|
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];
|
|
Slot slot = {
|
|
.rect = game.menu.slot_rect(isTrinket, index, count),
|
|
.size = game.menu.slot_size_get(count),
|
|
.item = item,
|
|
.level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[index],
|
|
.label = index + 1,
|
|
.isHoverable = true,
|
|
.isLit = isTrinket ? item >= 0 : index == fighter.slot,
|
|
.isTrinket = isTrinket,
|
|
.isTarget = game.menu.target_get(game, isTrinket) == index,
|
|
};
|
|
// In the HUD, slots are see-through until the mouse comes over them. Under the menu they're solid.
|
|
float hover = slotHovers[isTrinket][index];
|
|
game.renderer.layer_begin();
|
|
slot_draw(game, slot);
|
|
game.renderer.layer_end(game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover);
|
|
if (item >= 0 && is_point_in_rect(mouse, slot.rect))
|
|
hovered = slot;
|
|
}
|
|
|
|
if (hovered.item < 0 || game.menu.isOpen)
|
|
return;
|
|
Tooltip tooltip = tooltip_build(game, hovered.isTrinket, hovered.item, hovered.level);
|
|
tooltip.slot = hovered.label - 1;
|
|
tooltip_draw(game, tooltip);
|
|
}
|
|
|
|
// A bordered box with the item's icon, a stack of chevrons in the corner for its level, and an optional number (left
|
|
// off tiny boxes). Only full-size boxes have room for the hover border.
|
|
void Ui::slot_draw(Game& game, Slot slot)
|
|
{
|
|
Renderer& renderer = game.renderer;
|
|
bool isHovered = slot.isHoverable && !isFocusShown && is_point_in_rect(game.input.mouse, slot.rect);
|
|
Color color = color_fade(slot.isLit || isHovered || slot.isTarget ? tint : shade, slot.alpha);
|
|
panel_draw(renderer, slot.rect, color, -1, SLOT_STROKES[slot.size]);
|
|
if (slot.isTarget)
|
|
renderer.rect_draw({slot.rect.x + TARGET_INSET, slot.rect.y + slot.rect.h + TARGET_GAP,
|
|
slot.rect.w - TARGET_INSET * 2, TARGET_HEIGHT},
|
|
color);
|
|
if (isHovered && slot.size == ICON_FULL)
|
|
{
|
|
Rect rect = slot.rect;
|
|
float inset = PANEL_BORDER + HOVER_GAP;
|
|
chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2}, color);
|
|
inset += HOVER_BORDER;
|
|
chamfer_draw(renderer, {rect.x + inset, rect.y + inset, rect.w - inset * 2, rect.h - inset * 2},
|
|
color_fade(BLACK, slot.alpha));
|
|
}
|
|
|
|
if (slot.item >= 0)
|
|
{
|
|
icon_draw(game, slot.isTrinket, slot.item,
|
|
{.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}},
|
|
slot.size);
|
|
|
|
float inset = CHEVRON_INSETS[slot.size];
|
|
Vec2 corner = {slot.rect.x + slot.rect.w - inset, slot.rect.y + slot.rect.h - inset};
|
|
for (int i = 0; i <= slot.level; i++)
|
|
game.sprites.ui.draw(renderer, CHEVRONS[slot.size], 0,
|
|
{.color = color, .position = {corner.x, corner.y - i * CHEVRON_STEPS[slot.size]}});
|
|
}
|
|
|
|
if (slot.label <= 0 || slot.size == ICON_TINY)
|
|
return;
|
|
char label[] = {char('0' + slot.label), 0};
|
|
Vec2 offset = LABEL_OFFSETS[slot.size];
|
|
game.text.draw(renderer, label, {.color = color, .position = {slot.rect.x + offset.x, slot.rect.y + offset.y}},
|
|
LABEL_FONTS[slot.size]);
|
|
}
|
|
|
|
// A number for a stats table: whole when it's whole, otherwise to two decimal places at most.
|
|
static void number_format(char* text, int size, float value)
|
|
{
|
|
SDL_snprintf(text, size, "%g", std::round(value * 100) / 100);
|
|
}
|
|
|
|
// The stats table's last row, how far a weapon pushes its fighter.
|
|
constexpr int LUNGE_ROW = 8;
|
|
|
|
// What a summary row shows for an attack, or "" where it doesn't apply.
|
|
static void stat_format(char* text, int size, int row, const AttackSummary& summary, const AttackDef& attack,
|
|
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°", number);
|
|
}
|
|
break;
|
|
case LUNGE_ROW:
|
|
if (summary.lunge != 0)
|
|
SDL_snprintf(text, size, "%g", std::round(std::abs(summary.lunge)));
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Fills a weapon tooltip's stats table: a row per stat the weapon has (fired, damage, how often, range, blast, speed,
|
|
// how many out at once, spread, lunge), always in that order, for its attack and its charged attack as this fighter
|
|
// would fire them. A weapon that only pushes its fighter back calls its lunge Recoil. Values its trinkets change are
|
|
// marked; with a preview, ones the next level changes add "→ next".
|
|
void Ui::stats_table_build(Fighter& fighter, int weapon, int level, 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];
|
|
AttackSummary summaries[2] = {attack_summary_get(fighter, weapon, level, false),
|
|
attack_summary_get(fighter, weapon, level, true)};
|
|
AttackSummary nexts[2] = {};
|
|
for (int column = 0; column < 2 && isPreview; column++)
|
|
nexts[column] = attack_summary_get(fighter, weapon, level + 1, column == 1);
|
|
bool isRecoil = summaries[0].lunge <= 0 && summaries[1].lunge <= 0;
|
|
tooltip.rowCount = 0;
|
|
for (int row = 0; row < (int)std::size(LABELS); row++)
|
|
{
|
|
StatRow& stat = tooltip.rows[tooltip.rowCount];
|
|
stat = {.label = row == LUNGE_ROW && isRecoil ? "Recoil" : LABELS[row]};
|
|
bool isShown = false;
|
|
for (int column = 0; column < 2; column++)
|
|
{
|
|
bool isCharged = column == 1;
|
|
const AttackDef& attack = isCharged ? def.charged : def.normal;
|
|
AttackSummary& summary = summaries[column];
|
|
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, nexts[column], 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 an arrow, 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)
|
|
{
|
|
Tooltip tooltip;
|
|
tooltip.isTrinket = isTrinket;
|
|
tooltip.item = item;
|
|
Fighter& fighter = game.local_get();
|
|
int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout;
|
|
int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots;
|
|
tooltip.slot = std::find(loadout, loadout + slots, item) - loadout;
|
|
if (tooltip.slot >= slots)
|
|
tooltip.slot = -1;
|
|
|
|
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 an arrow, as weapons' stats do.
|
|
bool isPreview = game.sim.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 an arrow.
|
|
bool isPreview = game.sim.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)
|
|
tooltip_icon_draw(
|
|
game, tooltip,
|
|
{.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)
|
|
tooltip_icon_draw(game, tooltip,
|
|
{.color = tint,
|
|
.position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2,
|
|
panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}});
|
|
}
|
|
|
|
// A tooltip's item icon, centred on the placement, with the hotbar slot it's in numbered over its top left corner as on
|
|
// the hotbar.
|
|
void Ui::tooltip_icon_draw(Game& game, Tooltip& tooltip, Draw placement)
|
|
{
|
|
icon_draw(game, tooltip.isTrinket, tooltip.item, placement);
|
|
if (tooltip.slot < 0)
|
|
return;
|
|
char label[] = {char('1' + tooltip.slot), 0};
|
|
Vec2 corner = placement.position - Vec2{ICON_SIZE / 2, ICON_SIZE / 2} + ICON_LABEL_OFFSET;
|
|
game.text.draw(game.renderer, label, {.color = placement.color, .position = corner});
|
|
}
|
|
|
|
// Black box with a cut-cornered edge stroke thick in the given color (its corners cut as deep). The fill fades with
|
|
// the border unless given an alpha of its own.
|
|
void Ui::panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha, float stroke)
|
|
{
|
|
chamfer_draw(renderer, rect, border, stroke);
|
|
chamfer_draw(renderer, {rect.x + stroke, rect.y + stroke, rect.w - stroke * 2, rect.h - stroke * 2},
|
|
color_fade(BLACK, fillAlpha >= 0 ? fillAlpha : border.a), stroke);
|
|
}
|
|
|
|
// Fills a rect with its corners cut diagonally, cut pixels deep, one pixel per step.
|
|
void Ui::chamfer_draw(Renderer& renderer, Rect rect, Color color, float cut)
|
|
{
|
|
for (int step = 0; step < cut; step++)
|
|
{
|
|
float inset = cut - step;
|
|
renderer.rect_draw({rect.x + inset, rect.y + step, rect.w - inset * 2, 1}, color);
|
|
renderer.rect_draw({rect.x + inset, rect.y + rect.h - 1 - step, rect.w - inset * 2, 1}, color);
|
|
}
|
|
renderer.rect_draw({rect.x, rect.y + cut, rect.w, rect.h - cut * 2}, color);
|
|
}
|
|
|
|
void Ui::widgets_begin(float alpha, bool isLive)
|
|
{
|
|
widgetAlpha = alpha;
|
|
isWidgetLive = isLive;
|
|
isWidgetEnabled = true;
|
|
}
|
|
|
|
// Disabled widgets draw dimmed and ignore the mouse until enabled again.
|
|
void Ui::widgets_enable(bool isEnabled)
|
|
{
|
|
isWidgetEnabled = isEnabled;
|
|
}
|
|
|
|
// Under the mouse, while focus isn't showing (so only one thing lights at a time).
|
|
bool Ui::is_hovered(Game& game, Rect box)
|
|
{
|
|
return isWidgetLive && isWidgetEnabled && !isFocusShown && is_point_in_rect(game.input.mouse, box);
|
|
}
|
|
|
|
// Gray text at a widget row's height, for labels and readouts.
|
|
void Ui::label_draw(Game& game, const char* text, Vec2 position)
|
|
{
|
|
game.text.draw(game.renderer, text,
|
|
{.color = widget_color(false), .position = {position.x, position.y + TEXT_INSET}});
|
|
}
|
|
|
|
// 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 ((unsigned char)*character < GLYPH_FIRST || (unsigned char)*character >= GLYPH_TYPED_END)
|
|
continue;
|
|
text[length++] = *character;
|
|
text[length] = '\0';
|
|
}
|
|
isChanged = input.erases > 0 || input.typed[0] != '\0';
|
|
if (input.isSubmitted || !isWidgetLive || (input.isClicked && !isHovered))
|
|
widget_release(game);
|
|
}
|
|
|
|
panel_draw(game.renderer, box, widget_color(isHovered || isFocused || isTyping));
|
|
Vec2 corner = {box.x + FIELD_INSET, box.y + TEXT_INSET};
|
|
game.text.draw(game.renderer, text, {.color = widget_color(true), .position = corner});
|
|
bool isCaretShown = (int)(SDL_GetTicks() / (CARET_BLINK * SDL_MS_PER_SECOND)) % 2 == 0;
|
|
if (isTyping && isCaretShown)
|
|
{
|
|
float caretX = corner.x + game.text.size_get(text).x;
|
|
game.renderer.rect_draw({caretX, box.y + (box.h - CARET_HEIGHT) / 2, 1, CARET_HEIGHT}, widget_color(true));
|
|
}
|
|
return isChanged;
|
|
}
|
|
|
|
bool Ui::is_active(void* value)
|
|
{
|
|
return active == value;
|
|
}
|
|
|
|
Color Ui::widget_color(bool isLit)
|
|
{
|
|
return color_fade(isLit ? tint : shade, widgetAlpha * (isWidgetEnabled ? 1 : DISABLED_ALPHA));
|
|
}
|
|
|
|
// A number between a minus and a plus button that step it. Clicking the number types a new one, taken (clamped to the
|
|
// range) on Enter or clicking elsewhere. Focused, left and right step it.
|
|
bool Ui::number_draw(Game& game, Rect box, float& value, NumberRange range)
|
|
{
|
|
return number_widget(game, box, value, range, &value);
|
|
}
|
|
|
|
bool Ui::number_draw(Game& game, Rect box, int& value, NumberRange range)
|
|
{
|
|
float number = value;
|
|
bool isChanged = number_widget(game, box, number, range, &value);
|
|
value = std::round(number);
|
|
return isChanged;
|
|
}
|
|
|
|
// The number widget for a value known by id (its own address, which stays the same from frame to frame).
|
|
bool Ui::number_widget(Game& game, Rect box, float& value, NumberRange range, void* id)
|
|
{
|
|
Input& input = game.input;
|
|
Rect minus = {box.x, box.y, box.h, box.h};
|
|
Rect plus = {box.x + box.w - box.h, box.y, box.h, box.h};
|
|
Rect middle = {minus.x + minus.w + CHAMFER, box.y, box.w - box.h * 2 - CHAMFER * 2, box.h};
|
|
float before = value;
|
|
bool isTyping = active == id;
|
|
bool isFocused = focus_add(id, 0, box, true);
|
|
if (isFocused && adjust != 0 && !isTyping)
|
|
{
|
|
value = std::clamp(value + adjust * range.step, range.min, range.max);
|
|
game.audio.play(SOUND_SELECT);
|
|
}
|
|
|
|
for (Rect button : {minus, plus})
|
|
{
|
|
bool isHovered = is_hovered(game, button) || (isFocused && adjust == (button.x == minus.x ? -1 : 1));
|
|
if (isHovered && input.isClicked && !isTyping)
|
|
{
|
|
value = std::clamp(value + (button.x == minus.x ? -range.step : range.step), range.min, range.max);
|
|
game.audio.play(SOUND_SELECT);
|
|
}
|
|
panel_draw(game.renderer, button, widget_color(isHovered));
|
|
const char* sign = button.x == minus.x ? "-" : "+";
|
|
Vec2 size = game.text.size_get(sign);
|
|
game.text.draw(game.renderer, sign,
|
|
{.color = widget_color(isHovered),
|
|
.position = {std::round(button.x + (button.w - size.x) / 2), button.y + TEXT_INSET}});
|
|
}
|
|
|
|
bool isHovered = is_hovered(game, middle);
|
|
if (!isTyping && isHovered && input.isClicked)
|
|
{
|
|
active = id;
|
|
isTyping = true;
|
|
numberText[0] = '\0';
|
|
SDL_StartTextInput(game.window);
|
|
}
|
|
if (isTyping)
|
|
{
|
|
isActiveSeen = true;
|
|
int length = SDL_strlen(numberText);
|
|
for (int i = 0; i < input.erases && length > 0; i++)
|
|
numberText[--length] = '\0';
|
|
for (const char* character = input.typed; *character && length < (int)sizeof(numberText) - 1; character++)
|
|
{
|
|
if ((*character < '0' || *character > '9') && *character != '.')
|
|
continue;
|
|
numberText[length++] = *character;
|
|
numberText[length] = '\0';
|
|
}
|
|
if (input.isSubmitted || !isWidgetLive || (input.isClicked && !isHovered))
|
|
{
|
|
if (numberText[0] != '\0')
|
|
value = std::clamp((float)SDL_atof(numberText), range.min, range.max);
|
|
widget_release(game);
|
|
isTyping = false;
|
|
}
|
|
}
|
|
|
|
char text[TOOLTIP_LENGTH];
|
|
if (isTyping)
|
|
SDL_strlcpy(text, numberText, sizeof(text));
|
|
else if (value == 0 && range.zero)
|
|
SDL_strlcpy(text, range.zero, sizeof(text));
|
|
else
|
|
SDL_snprintf(text, sizeof(text), "%g%s", value, range.suffix);
|
|
panel_draw(game.renderer, middle, widget_color(isHovered || isFocused || isTyping));
|
|
Vec2 size = game.text.size_get(text);
|
|
float textX = std::round(middle.x + (middle.w - size.x) / 2);
|
|
game.text.draw(game.renderer, text, {.color = widget_color(true), .position = {textX, middle.y + TEXT_INSET}});
|
|
bool isCaretShown = (int)(SDL_GetTicks() / (CARET_BLINK * SDL_MS_PER_SECOND)) % 2 == 0;
|
|
if (isTyping && isCaretShown)
|
|
game.renderer.rect_draw({textX + size.x, middle.y + (middle.h - CARET_HEIGHT) / 2, 1, CARET_HEIGHT},
|
|
widget_color(true));
|
|
return value != before;
|
|
}
|
|
|
|
// A section title 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* preview = player.animation_get(PREVIEW_ANIMATION);
|
|
float frame = preview ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)preview->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_BARS_WIDTH = PING_BARS * (PING_BAR_WIDTH + PING_BAR_GAP) - PING_BAR_GAP;
|
|
constexpr float PING_BAR_STEP = 2;
|
|
constexpr float PING_BARS_HEIGHT = PING_BARS * PING_BAR_STEP;
|
|
constexpr float PING_BARS_DROP = 3;
|
|
constexpr float PING_UNLIT_SHADE = 0.5f;
|
|
constexpr float PING_LIMITS[PING_BARS - 1] = {60, 120, 200};
|
|
constexpr Color PING_COLORS[PING_BARS] = {
|
|
{0.9f, 0.25f, 0.2f, 1}, {0.95f, 0.6f, 0.2f, 1}, {0.9f, 0.85f, 0.25f, 1}, {0.35f, 0.85f, 0.35f, 1}};
|
|
|
|
static std::string ping_text_get(float ping)
|
|
{
|
|
return ping < 0 ? std::string() : std::to_string((int)std::round(ping)) + "ms";
|
|
}
|
|
|
|
float Ui::ping_width_get(Game& game, float ping)
|
|
{
|
|
std::string text = ping_text_get(ping);
|
|
return PING_BARS_WIDTH + (text.empty() ? 0 : PING_BAR_GAP * 2 + game.text.size_get(text.c_str()).x);
|
|
}
|
|
|
|
// How many of a connection's bars are lit (none while it's unknown).
|
|
static int ping_lit_get(float ping)
|
|
{
|
|
if (ping < 0)
|
|
return 0;
|
|
int lit = PING_BARS;
|
|
for (float limit : PING_LIMITS)
|
|
lit -= ping >= limit;
|
|
return std::max(lit, 1);
|
|
}
|
|
|
|
float Ui::ping_draw(Game& game, Vec2 corner, float ping, float alpha)
|
|
{
|
|
ping_bars_draw(game, {corner.x, corner.y + LINE_HEIGHT - PING_BARS_DROP}, ping, alpha);
|
|
int lit = ping_lit_get(ping);
|
|
std::string text = ping_text_get(ping);
|
|
if (!text.empty())
|
|
game.text.draw(game.renderer, text.c_str(),
|
|
{.color = color_fade(PING_COLORS[lit - 1], alpha),
|
|
.position = {std::round(corner.x) + PING_BARS_WIDTH + PING_BAR_GAP * 2, corner.y}});
|
|
return ping_width_get(game, ping);
|
|
}
|
|
|
|
// A connection's signal bars alone, standing on corner (their bottom left).
|
|
void Ui::ping_bars_draw(Game& game, Vec2 corner, float ping, float alpha)
|
|
{
|
|
int lit = ping_lit_get(ping);
|
|
Color color = lit > 0 ? PING_COLORS[lit - 1] : shade;
|
|
float x = std::round(corner.x);
|
|
float bottom = std::round(corner.y);
|
|
for (int i = 0; i < PING_BARS; i++)
|
|
{
|
|
float height = PING_BAR_STEP * (i + 1);
|
|
game.renderer.rect_draw({x, bottom - height, PING_BAR_WIDTH, height},
|
|
color_fade(i < lit ? color : color_shade(shade, PING_UNLIT_SHADE), alpha));
|
|
x += PING_BAR_WIDTH + PING_BAR_GAP;
|
|
}
|
|
}
|
|
|
|
// What a game's rules make for, in a few lines under a title with its mode's icon: what it's about, how many play, the
|
|
// slots, how items are handed out, and how it's won. The "game tooltip".
|
|
static std::vector<std::string> game_summary_lines(const Rules& rules)
|
|
{
|
|
std::vector<std::string> lines;
|
|
bool isRace = rules.mode == MODE_RACE;
|
|
lines.push_back(isRace ? "- First to the finish wins!" : "- Fight to the death!");
|
|
lines.push_back("- " + std::to_string(rules.players) + " players");
|
|
auto count = [](int n, const char* one) {
|
|
return std::to_string(n) + " " + one + (n == 1 ? "" : "s");
|
|
};
|
|
lines.push_back("- " + count(rules.weapons.slots, "weapon") + ", " + count(rules.trinkets.slots, "trinket"));
|
|
auto when = [](const ItemRules& items) {
|
|
if (items.timing == PICK_EVERY_ROUND)
|
|
return std::string("every round");
|
|
if (items.timing == PICK_PERIOD)
|
|
return "every " + std::to_string(items.period) + " rounds";
|
|
return std::string("on the first round");
|
|
};
|
|
auto handed = [&](const ItemRules& items, const char* one) {
|
|
std::string what = std::string(one) + (items.slots == 1 ? "" : "s");
|
|
return (items.isChoice ? "Choose " + what : "Random " + what) + " " + when(items);
|
|
};
|
|
const ItemRules& weapons = rules.weapons;
|
|
const ItemRules& trinkets = rules.trinkets;
|
|
if (weapons.isChoice == trinkets.isChoice && weapons.timing == trinkets.timing &&
|
|
(weapons.timing != PICK_PERIOD || weapons.period == trinkets.period))
|
|
lines.push_back(std::string("- ") + (weapons.isChoice ? "Choose" : "Random") + " weapon" +
|
|
(weapons.slots == 1 ? "" : "s") + " and trinket" + (trinkets.slots == 1 ? "" : "s") + " " +
|
|
when(weapons));
|
|
else
|
|
{
|
|
lines.push_back("- " + handed(weapons, "weapon"));
|
|
lines.push_back("- " + handed(trinkets, "trinket"));
|
|
}
|
|
if (isRace)
|
|
lines.push_back(rules.timer > 0 ? "- " + std::to_string(rules.timer) + " minute time limit"
|
|
: "- No time limit");
|
|
else
|
|
lines.push_back("- Best of " + std::to_string(rules.bestOf));
|
|
return lines;
|
|
}
|
|
|
|
Vec2 Ui::game_summary_size(Game& game, const Rules& rules)
|
|
{
|
|
float width = ICON_SIZE + SUMMARY_ICON_GAP + game.text.size_get("Game: Deathmatch").x;
|
|
std::vector<std::string> lines = game_summary_lines(rules);
|
|
for (std::string& line : lines)
|
|
width = std::max(width, game.text.size_get(line.c_str()).x);
|
|
return {width, ICON_SIZE + lines.size() * LINE_HEIGHT};
|
|
}
|
|
|
|
// The game's summary from its top left corner, returning how tall it is.
|
|
float Ui::game_summary_draw(Game& game, const Rules& rules, Vec2 corner, float alpha)
|
|
{
|
|
bool isRace = rules.mode == MODE_RACE;
|
|
const char* mode = isRace ? "Race" : "Deathmatch";
|
|
Color color = color_fade(tint, alpha);
|
|
game.sprites.modes.draw(game.renderer, mode, 0,
|
|
{.color = color, .position = {corner.x + ICON_SIZE / 2, corner.y + ICON_SIZE / 2}});
|
|
std::string title = std::string("Game: ") + mode;
|
|
game.text.draw(
|
|
game.renderer, title.c_str(),
|
|
{.color = color,
|
|
.position = {corner.x + ICON_SIZE + SUMMARY_ICON_GAP, std::round(corner.y + (ICON_SIZE - LINE_HEIGHT) / 2)}});
|
|
float y = corner.y + ICON_SIZE;
|
|
for (std::string& line : game_summary_lines(rules))
|
|
{
|
|
game.text.draw(game.renderer, line.c_str(), {.color = color_fade(shade, alpha), .position = {corner.x, y}});
|
|
y += LINE_HEIGHT;
|
|
}
|
|
return y - corner.y;
|
|
}
|
|
|
|
// Starts laying rows into an area, scrolled by the wheel while the mouse is over it, or by moving the focus past its
|
|
// top or bottom row.
|
|
void Ui::page_begin(Game& game, Page& page, Rect area)
|
|
{
|
|
page.area = area;
|
|
if (isWidgetLive && is_point_in_rect(game.input.mouse, area))
|
|
page.scroll -= game.input.scroll * SCROLL_STEP;
|
|
float maxScroll = std::max(0.0f, page.height - area.h);
|
|
float before = std::clamp(page.scroll, 0.0f, maxScroll);
|
|
page.scroll = std::clamp(page.scroll + scroll_take(area) * SCROLL_STEP, 0.0f, maxScroll);
|
|
if (page.scroll != before)
|
|
scroll_mark();
|
|
page.cursor = area.y;
|
|
}
|
|
|
|
// Takes the next row of a page, giving where it sits and whether it's fully inside the area (only then drawn).
|
|
bool Ui::row_get(Page& page, float height, float& y)
|
|
{
|
|
y = page.cursor - page.scroll;
|
|
page.cursor += height;
|
|
return y >= page.area.y && y + height <= page.area.y + page.area.h;
|
|
}
|
|
|
|
// Ends a page, drawing a scroll bar down its right edge when its rows don't all fit.
|
|
void Ui::page_end(Game& game, Page& page)
|
|
{
|
|
page.height = page.cursor - page.area.y;
|
|
if (page.height <= page.area.h)
|
|
return;
|
|
Rect track = {page.area.x + page.area.w - 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 elements change while the mouse is over them, and change back after.
|
|
Vec2 mouse = game.input.mouse;
|
|
auto hover_step = [&](float& hover, bool isHovered) {
|
|
hover = std::clamp(hover + (isHovered ? 1 : -1) * TICK / HUD_HOVER_FADE, 0.0f, 1.0f);
|
|
};
|
|
Fighter& local = game.local_get();
|
|
for (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]));
|
|
}
|
|
for (int slot = 0; slot < SLOT_MAX; slot++)
|
|
{
|
|
hover_step(weaponBars[slot].hover, weaponBars[slot].fade > 0 && is_point_in_rect(mouse, weaponBars[slot].rect));
|
|
hover_step(trinketBars[slot].hover,
|
|
trinketBars[slot].fade > 0 && is_point_in_rect(mouse, trinketBars[slot].rect));
|
|
}
|
|
hover_step(statusHover, is_point_in_rect(mouse, statusRect));
|
|
|
|
// Every slot cools on its own bar: weapons whose cooldown is long enough to notice (copies that don't stack share
|
|
// the first one's), and trinkets with a cooldown of their own (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, a row each: weapons downward from under the weapon hotbar, trinkets upward
|
|
// from over the trinket hotbar. A kind's bars are two of its slots long, with its icon just right at its hotbar's
|
|
// size, and start far enough off the hotbar that the icons (taller than the bars) clear its slots. Each drains with
|
|
// its cooldown and fades with its bar.
|
|
void Ui::cooldowns_draw(Game& game)
|
|
{
|
|
Fighter& fighter = game.local_get();
|
|
for (int kind = 0; kind < 2; kind++)
|
|
{
|
|
bool isTrinket = kind == 1;
|
|
IconSize size = game.menu.slot_size_get(isTrinket ? fighter.trinketSlots : fighter.weaponSlots);
|
|
float height = COOLDOWN_BAR_HEIGHTS[size];
|
|
float icon = ICON_PIXELS[size];
|
|
float width = COOLDOWN_SLOTS * (SLOT_SIZES[size] + SLOT_GAPS[size]) - SLOT_GAPS[size];
|
|
float spacing = std::max(icon, height) + COOLDOWN_ROW_GAP;
|
|
float start = HOTBAR_MARGIN + SLOT_SIZES[size] + COOLDOWN_GAP + std::ceil(std::max(0.0f, (icon - height) / 2));
|
|
CooldownBar* bars = isTrinket ? trinketBars : weaponBars;
|
|
for (int slot = 0; slot < SLOT_MAX; slot++)
|
|
{
|
|
CooldownBar& bar = bars[slot];
|
|
if (bar.fade <= 0 || bar.row < 0 || bar.item < 0)
|
|
continue;
|
|
float step = std::round(bar.row * spacing);
|
|
Rect rect = {HOTBAR_MARGIN, isTrinket ? CANVAS_HEIGHT - start - height - step : start + step, width,
|
|
height};
|
|
float middle = rect.y + height / 2;
|
|
bar.rect = {rect.x, middle - spacing / 2, width + COOLDOWN_GAP + icon, spacing};
|
|
game.renderer.layer_begin();
|
|
bar_draw(game.renderer, rect, bar.fill, tint, SLOT_STROKES[size]);
|
|
icon_draw(game, isTrinket, bar.item,
|
|
{.color = tint, .position = {std::round(rect.x + width + COOLDOWN_GAP + icon / 2), middle}},
|
|
size);
|
|
game.renderer.layer_end(bar.fade * hover_alpha_get(bar.hover));
|
|
}
|
|
}
|
|
}
|
|
|
|
// A weapon's or trinket's HUD icon, centred on the placement.
|
|
void Ui::icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSize size)
|
|
{
|
|
char name[TOOLTIP_LENGTH];
|
|
SDL_snprintf(name, sizeof(name), "%s%s", isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name,
|
|
ICON_SUFFIXES[size]);
|
|
(isTrinket ? game.sprites.trinkets : game.sprites.icons).draw(game.renderer, name, 0, placement);
|
|
}
|
|
|
|
// An arrow at the screen's edge for every other living fighter out of view, pointing toward it, in its 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.sim.fighterCount; i++)
|
|
{
|
|
Fighter& fighter = game.sim.fighters[i];
|
|
Vec2 offset = fighter.center_get() - camera - half;
|
|
bool isVisible = std::abs(offset.x) < half.x && std::abs(offset.y) < half.y;
|
|
if (i == game.local || !fighter.isAlive || isVisible)
|
|
continue;
|
|
|
|
float reach = std::min((half.x - OFFSCREEN_MARGIN) / std::max(1.0f, std::abs(offset.x)),
|
|
(half.y - OFFSCREEN_MARGIN) / std::max(1.0f, std::abs(offset.y)));
|
|
Vec2 edge = half + offset * reach;
|
|
Color color = game.color_get(i);
|
|
game.sprites.ui.draw(game.renderer, "Arrow", 0,
|
|
{.color = color,
|
|
.position = edge,
|
|
.scale = {OFFSCREEN_ARROW_SCALE, OFFSCREEN_ARROW_SCALE},
|
|
.angle = vec2_angle(offset) + PI / 2});
|
|
|
|
const char* name = name_get(game, i);
|
|
Vec2 size = game.text.size_get(name);
|
|
Vec2 label = edge - vec2_normalize(offset) * (OFFSCREEN_NAME_GAP + size.x / 2) - size * 0.5f;
|
|
label.x = std::clamp(label.x, 0.0f, CANVAS_WIDTH - size.x);
|
|
label.y = std::clamp(label.y, 0.0f, CANVAS_HEIGHT - size.y);
|
|
game.text.draw(game.renderer, name, {.color = color, .position = {std::round(label.x), std::round(label.y)}});
|
|
}
|
|
}
|
|
|
|
// Each of this screen's fighters' crosshairs in its colour, filling in and shaking with its weapon's charge and
|
|
// cooldown: at the mouse for the keyboard's (and whenever the menu is open), otherwise where its stick aims. With none
|
|
// (a bot playing), the mouse's.
|
|
void Ui::crosshairs_draw(Game& game, Vec2 camera)
|
|
{
|
|
Input& input = game.input;
|
|
bool isAny = false;
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
{
|
|
if (!game.is_local(i))
|
|
continue;
|
|
isAny = true;
|
|
bool isMouse = (game.devices[i] == DEVICE_KEYBOARD && !input.devices[DEVICE_KEYBOARD].isAiming) ||
|
|
(i == game.local && game.menu.isOpen);
|
|
Fighter& fighter = game.sim.fighters[i];
|
|
Vec2 aim = (isMouse ? input.mouse : fighter.controls.cursor - camera) +
|
|
game.scene.weapon_shake_get(fighter, fighter.weapon_key(fighter.slot));
|
|
game.sprites.ui.draw(game.renderer, "Crosshair", 0,
|
|
{.color = game.color_get(i),
|
|
.position = {std::floor(aim.x), std::floor(aim.y)},
|
|
.flash = game.scene.charge_fill_get(fighter)});
|
|
}
|
|
if (!isAny)
|
|
game.sprites.ui.draw(game.renderer, "Crosshair", 0,
|
|
{.color = tint, .position = {std::floor(input.mouse.x), std::floor(input.mouse.y)}});
|
|
}
|
|
|
|
// With several players on this screen in a match, a compact panel each along the bottom in their 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.sim.fighterCount; i++)
|
|
if (game.is_local(i))
|
|
players[count++] = i;
|
|
float width = (CANVAS_WIDTH - HOTBAR_MARGIN * 2 - LOADOUT_GAP * (count - 1)) / count;
|
|
float top = CANVAS_HEIGHT - HOTBAR_MARGIN - PLAYER_PANEL_HEIGHT;
|
|
for (int k = 0; k < count; k++)
|
|
{
|
|
int index = players[k];
|
|
Fighter& fighter = game.sim.fighters[index];
|
|
Color color = game.color_get(index);
|
|
Color dim = color_shade(color, SECONDARY_SHADE);
|
|
Rect panel = {std::round(HOTBAR_MARGIN + k * (width + LOADOUT_GAP)), top, std::floor(width),
|
|
PLAYER_PANEL_HEIGHT};
|
|
panel_draw(game.renderer, panel, color);
|
|
float x = panel.x + PICK_PADDING;
|
|
float y = panel.y + PICK_PADDING;
|
|
const char* name = name_get(game, index);
|
|
game.text.draw(game.renderer, name, {.color = color, .position = {x, y}});
|
|
if (!game.sim.match.is_racing())
|
|
{
|
|
float barX = x + game.text.size_get(name).x + PICK_PADDING;
|
|
Rect bar = {barX, y + 2, panel.x + panel.w - PICK_PADDING - barX, HEALTH_BAR_HEIGHT};
|
|
bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / game.sim.match.health_get(), color);
|
|
}
|
|
int fit = (panel.w - PICK_PADDING * 2 + MINI_STEP - MINI_SLOT) / MINI_STEP;
|
|
for (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.sim.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.sim.fighterCount; i++)
|
|
if (game.sim.fighters[i].isAlive)
|
|
return true;
|
|
return false;
|
|
}
|
|
if ((state != MATCH_FIGHT && state != MATCH_ROUND_OVER) || game.local_count() != 1 || game.local_get().isAlive)
|
|
return false;
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
if (i != game.local && game.sim.fighters[i].isAlive)
|
|
return true;
|
|
return false;
|
|
}
|
|
|
|
// Picks who to watch while spectating. It starts on whoever took this screen's fighter out, and moves on to whoever
|
|
// takes the watched one out (or the next one alive). Left and Right step back and forth through the living, as does
|
|
// Attack (forward). Presses are caught here, against last tick's state, since the tick's own edges are gone by now.
|
|
void Ui::spectate_update(Game& game)
|
|
{
|
|
Input& input = game.input;
|
|
bool isWatching = game.session.is_watching();
|
|
int device = isWatching ? DEVICE_KEYBOARD : game.devices[game.local];
|
|
bool held[3] = {};
|
|
if (device != DEVICE_NONE)
|
|
for (int i = 0; i < 3; i++)
|
|
held[i] = input.is_control_down(device, SPECTATE_CONTROLS[i]);
|
|
bool isPressed[3];
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
isPressed[i] = held[i] && !spectateHeld[i];
|
|
spectateHeld[i] = held[i];
|
|
}
|
|
if (!is_spectating(game))
|
|
{
|
|
spectated = -1;
|
|
return;
|
|
}
|
|
|
|
auto is_watchable = [&](int fighter) {
|
|
return fighter >= 0 && fighter < game.sim.fighterCount && (fighter != game.local || isWatching) &&
|
|
game.sim.fighters[fighter].isAlive;
|
|
};
|
|
auto next_get = [&](int from, int step) {
|
|
for (int k = 1; k <= game.sim.fighterCount; k++)
|
|
{
|
|
int fighter = ((from + step * k) % game.sim.fighterCount + game.sim.fighterCount) % game.sim.fighterCount;
|
|
if (is_watchable(fighter))
|
|
return fighter;
|
|
}
|
|
return from;
|
|
};
|
|
if (!is_watchable(spectated))
|
|
{
|
|
int taker = spectated >= 0 ? game.sim.fighters[spectated].launcher : game.local_get().launcher;
|
|
spectated = is_watchable(taker) ? taker : next_get(spectated >= 0 ? spectated : game.local, 1);
|
|
return;
|
|
}
|
|
int step = (isPressed[1] || isPressed[2]) - isPressed[0];
|
|
if (step != 0 && !game.menu.isOpen)
|
|
{
|
|
spectated = next_get(spectated, step);
|
|
game.audio.play(SOUND_SELECT);
|
|
}
|
|
}
|
|
|
|
// While spectating, who's being watched, in their 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);
|
|
}
|
|
|
|
// A fighter's connection for its window, online (PING_NONE otherwise).
|
|
static float rival_ping_get(Game& game, int fighter)
|
|
{
|
|
return game.session.isActive ? game.session.ping_get(game, game.sim.match.owners[fighter]) : PING_NONE;
|
|
}
|
|
|
|
// Lays out the other fighters' windows in the HUD's right column, from the bottom up to the readouts above, in no more
|
|
// than two rows: as many to a row as fit across (the rows as even as they go). A window is as wide as its icons, name
|
|
// or health (with its connection's bars, online). Without icons, the health number can go to make room. Full-size
|
|
// icons stack no more than two rows deep across the windows. When they don't all fit, the icons shrink to small, then
|
|
// the font to tiny, then the icons to tiny, then the icons are left out. Each kind of icon wraps onto more rows past
|
|
// three across, and tiny icons wrap further if they still don't fit. If even names and health alone don't fit, the
|
|
// windows split the column evenly and the names are cut short.
|
|
RivalLayout Ui::rivals_layout(Game& game)
|
|
{
|
|
RivalLayout layout;
|
|
int slots[2] = {};
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
{
|
|
if (i == game.local && !game.session.is_watching())
|
|
continue;
|
|
layout.fighters[layout.count++] = i;
|
|
slots[0] = std::max(slots[0], game.sim.fighters[i].weaponSlots);
|
|
slots[1] = std::max(slots[1], game.sim.fighters[i].trinketSlots);
|
|
}
|
|
if (layout.count == 0)
|
|
return layout;
|
|
|
|
float top = statusRect.h > 0 ? statusRect.y + statusRect.h + RIVAL_GAP : HOTBAR_MARGIN;
|
|
float room = CANVAS_HEIGHT - HOTBAR_MARGIN - top;
|
|
float inset = PANEL_BORDER + RIVAL_PADDING;
|
|
bool isRace = game.sim.match.is_racing();
|
|
char health[TOOLTIP_LENGTH];
|
|
SDL_snprintf(health, sizeof(health), "%d", (int)std::ceil(game.sim.match.health_get()));
|
|
float width = 0;
|
|
float height = 0;
|
|
int perRow = 1;
|
|
bool isFit = false;
|
|
for (int style = 0; style < RIVAL_STYLES && !isFit; style++)
|
|
{
|
|
layout.font = RIVAL_FONTS[style];
|
|
layout.isIconShown = style < RIVAL_ICON_STYLES;
|
|
layout.size = layout.isIconShown ? RIVAL_SIZES[style] : ICON_TINY;
|
|
layout.line = game.text.line_height_get(layout.font);
|
|
float text = 0;
|
|
float ping = 0;
|
|
for (int k = 0; k < layout.count; k++)
|
|
{
|
|
int fighter = layout.fighters[k];
|
|
ping = std::max(ping, rival_ping_get(game, fighter) == PING_NONE ? 0 : NAME_PING_GAP + PING_BARS_WIDTH);
|
|
text = std::max(text, game.text.size_get(name_get(game, fighter), layout.font).x);
|
|
}
|
|
float number = layout.isIconShown ? game.text.size_get(health, layout.font).x + RIVAL_PADDING : 0;
|
|
text = isRace ? text + ping : std::max(text, number + RIVAL_BAR_MIN + ping);
|
|
|
|
// No more than a few of a kind side by side, wrapping onto more rows to keep to that. Tiny icons can wrap
|
|
// further to fit.
|
|
float cell = ICON_PIXELS[layout.size] + RIVAL_ICON_GAP;
|
|
int rowsMin = std::max(1, (std::max(slots[0], slots[1]) + RIVAL_COLUMNS_MAX - 1) / RIVAL_COLUMNS_MAX);
|
|
int rowsMax = layout.isIconShown && layout.size == ICON_TINY ? SLOT_MAX : rowsMin;
|
|
for (int rows = rowsMin; rows <= rowsMax && !isFit; rows++)
|
|
{
|
|
layout.rows = layout.isIconShown ? rows : 0;
|
|
for (int kind = 0; kind < 2; kind++)
|
|
layout.columns[kind] = (slots[kind] + rows - 1) / rows;
|
|
float icons = layout.isIconShown
|
|
? (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP
|
|
: 0;
|
|
width = std::ceil(std::max(icons, text) + inset * 2);
|
|
height = inset * 2 + layout.line + (isRace ? 0 : RIVAL_PADDING + layout.line) +
|
|
(layout.isIconShown ? RIVAL_PADDING + rows * cell - RIVAL_ICON_GAP : 0);
|
|
perRow = std::clamp((int)((HUD_COLUMN_WIDTH + RIVAL_GAP) / (width + RIVAL_GAP)), 1, layout.count);
|
|
int cardRows = (layout.count + perRow - 1) / perRow;
|
|
perRow = (layout.count + cardRows - 1) / cardRows;
|
|
bool isFullTall = layout.size == ICON_FULL && cardRows * layout.rows > RIVAL_FULL_ICON_ROWS_MAX;
|
|
isFit = width <= HUD_COLUMN_WIDTH && cardRows <= RIVAL_ROWS_MAX && !isFullTall &&
|
|
cardRows * (height + RIVAL_GAP) - RIVAL_GAP <= room;
|
|
}
|
|
}
|
|
if (!isFit)
|
|
{
|
|
perRow = (layout.count + RIVAL_ROWS_MAX - 1) / RIVAL_ROWS_MAX;
|
|
width = std::floor((HUD_COLUMN_WIDTH - (perRow - 1) * RIVAL_GAP) / perRow);
|
|
}
|
|
|
|
for (int k = 0; k < layout.count; k++)
|
|
{
|
|
int row = k / perRow;
|
|
int inRow = std::min(perRow, layout.count - row * perRow);
|
|
float x = CANVAS_WIDTH - HOTBAR_MARGIN - inRow * width - (inRow - 1) * RIVAL_GAP;
|
|
float y = CANVAS_HEIGHT - HOTBAR_MARGIN - height - row * (height + RIVAL_GAP);
|
|
layout.cards[k] = {std::round(x + (k % perRow) * (width + RIVAL_GAP)), y, width, height};
|
|
}
|
|
return layout;
|
|
}
|
|
|
|
// Every other fighter's window in a match, bordered in its colour: its name centred (cut short if it doesn't fit), its
|
|
// health (online with its connection's bars after), then (with room) its weapons, a divider, and its trinkets, the
|
|
// weapon in hand lit. Each is laid over the game as one piece, half see-through (half again for a fighter out of the
|
|
// round), and fades by half again while the mouse is over it.
|
|
void Ui::rivals_draw(Game& game)
|
|
{
|
|
Match& match = game.sim.match;
|
|
if (match.state == MATCH_PRACTICE || match.state == MATCH_COUNTDOWN)
|
|
return;
|
|
Renderer& renderer = game.renderer;
|
|
RivalLayout layout = rivals_layout(game);
|
|
float inset = PANEL_BORDER + RIVAL_PADDING;
|
|
float iconSize = ICON_PIXELS[layout.size];
|
|
float cell = iconSize + RIVAL_ICON_GAP;
|
|
for (int k = 0; k < layout.count; k++)
|
|
{
|
|
int index = layout.fighters[k];
|
|
Fighter& fighter = game.sim.fighters[index];
|
|
Rect card = layout.cards[k];
|
|
float base = RIVAL_ALPHA * (fighter.isAlive || match.is_racing() || index == spectated ? 1 : RIVAL_OUT_SHARE);
|
|
Color color = game.color_get(index);
|
|
Color dim = color_shade(color, SECONDARY_SHADE);
|
|
renderer.layer_begin();
|
|
panel_draw(renderer, card, color);
|
|
|
|
float y = card.y + inset;
|
|
Font font = layout.font;
|
|
// The connection's bars go at the end of the health row, or beside the name in a race.
|
|
bool isRace = match.is_racing();
|
|
float ping = rival_ping_get(game, index);
|
|
float pingWidth = ping == PING_NONE ? 0 : NAME_PING_GAP + PING_BARS_WIDTH;
|
|
float nameRoom = card.w - inset * 2 - (isRace ? pingWidth : 0);
|
|
std::string name = name_get(game, index);
|
|
std::string shown = name;
|
|
while (game.text.size_get(shown.c_str(), font).x > nameRoom && !name.empty())
|
|
{
|
|
name.pop_back();
|
|
shown = name + "...";
|
|
}
|
|
Vec2 size = game.text.size_get(shown.c_str(), font);
|
|
float left = std::round(card.x + (card.w - size.x - (isRace ? pingWidth : 0)) / 2);
|
|
game.text.draw(renderer, shown.c_str(),
|
|
{.color = color, .position = {left, std::round(y + (layout.line - size.y) / 2)}}, font);
|
|
float pingBottom = std::round(y + (layout.line + PING_BARS_HEIGHT) / 2);
|
|
if (isRace && ping != PING_NONE)
|
|
ping_bars_draw(game, {left + size.x + NAME_PING_GAP, pingBottom}, ping, 1);
|
|
y += layout.line + RIVAL_PADDING;
|
|
|
|
// Its health as a bar, with the number before it when there's room.
|
|
if (!isRace)
|
|
{
|
|
char text[TOOLTIP_LENGTH];
|
|
SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health)));
|
|
size = game.text.size_get(text, font);
|
|
float x = card.x + inset;
|
|
float right = card.x + card.w - inset - pingWidth;
|
|
if (size.x + RIVAL_PADDING + RIVAL_BAR_MIN <= right - x)
|
|
{
|
|
game.text.draw(renderer, text,
|
|
{.color = color, .position = {x, std::round(y + (layout.line - size.y) / 2)}}, font);
|
|
x += size.x + RIVAL_PADDING;
|
|
}
|
|
float barHeight = std::min(HEALTH_BAR_HEIGHT, layout.line - 1);
|
|
Rect bar = {x, std::round(y + (layout.line - barHeight) / 2), right - x, barHeight};
|
|
bar_draw(renderer, bar, std::clamp(fighter.health / match.health_get(), 0.0f, 1.0f), color);
|
|
if (ping != PING_NONE)
|
|
ping_bars_draw(game, {right + NAME_PING_GAP, pingBottom + layout.line + RIVAL_PADDING}, ping, 1);
|
|
y += layout.line + RIVAL_PADDING;
|
|
}
|
|
|
|
// Weapons then trinkets, each in its columns, centred with the divider between.
|
|
if (!layout.isIconShown)
|
|
{
|
|
renderer.layer_end(base * hover_alpha_get(rivalHovers[index]));
|
|
continue;
|
|
}
|
|
float iconsWidth = (layout.columns[0] + layout.columns[1]) * cell + RIVAL_DIVIDER - RIVAL_ICON_GAP;
|
|
float x = std::round(card.x + (card.w - iconsWidth) / 2);
|
|
for (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++)
|
|
{
|
|
int item = loadout[slot];
|
|
if (item < 0)
|
|
continue;
|
|
bool isLit = isTrinket || slot == fighter.slot;
|
|
Vec2 centre = {x + slot % layout.columns[kind] * cell + iconSize / 2,
|
|
y + slot / layout.columns[kind] * cell + iconSize / 2};
|
|
icon_draw(game, isTrinket, item, {.color = isLit ? color : dim, .position = centre}, layout.size);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
renderer.layer_end(base * hover_alpha_get(rivalHovers[index]));
|
|
}
|
|
}
|
|
|
|
// Choosing items with several players on this screen: a panel each below top (side by side for two, a 2x2 grid for
|
|
// more).
|
|
void Ui::picks_draw(Game& game, float top)
|
|
{
|
|
int players[MAX_FIGHTERS];
|
|
int count = 0;
|
|
for (int i = 0; i < game.sim.fighterCount; i++)
|
|
if (game.is_local(i))
|
|
players[count++] = i;
|
|
int columns = std::min(count, 2);
|
|
int rows = (count + 1) / 2;
|
|
float width = (CANVAS_WIDTH - HOTBAR_MARGIN * 2 - LOADOUT_GAP * (columns - 1)) / columns;
|
|
float height = (CANVAS_HEIGHT - HOTBAR_MARGIN - top - LOADOUT_GAP * (rows - 1)) / rows;
|
|
for (int k = 0; k < count; k++)
|
|
pick_panel_draw(game, players[k],
|
|
{std::round(HOTBAR_MARGIN + k % columns * (width + LOADOUT_GAP)),
|
|
std::round(top + k / columns * (height + LOADOUT_GAP)), std::floor(width),
|
|
std::floor(height)});
|
|
}
|
|
|
|
// One player's choosing panel in their 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.sim.match;
|
|
Input& input = game.input;
|
|
Fighter& fighter = game.sim.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.sim.match;
|
|
if (!game.session.isActive || match.state != MATCH_PRACTICE)
|
|
return;
|
|
Renderer& renderer = game.renderer;
|
|
Input& input = game.input;
|
|
Vec2 mouse = input.mouse + renderer.camera;
|
|
float bob = std::round(math_sin(SDL_GetTicks() / 1000.0f * QUEUE_ICON_SPEED) * QUEUE_ICON_BOB);
|
|
for (int q = 0; q < game.sim.fighterCount; q++)
|
|
{
|
|
Queue& queue = match.queues[q];
|
|
if (!queue.isActive)
|
|
continue;
|
|
Color color = game.color_get(q, true);
|
|
Rect area = queue.area;
|
|
int members[MAX_FIGHTERS];
|
|
int count = match.queue_members_get(game.sim, q, members);
|
|
renderer.rect_draw(area, color_fade(color, QUEUE_FILL_ALPHA));
|
|
Color edge = color_fade(color, QUEUE_EDGE_ALPHA);
|
|
renderer.rect_draw({area.x, area.y, 1, area.h}, edge);
|
|
renderer.rect_draw({area.x + area.w - 1, area.y, 1, area.h}, edge);
|
|
renderer.rect_draw({area.x, area.y, area.w, 1}, edge);
|
|
for (int k = 0; k < queue.rules.players; k++)
|
|
{
|
|
float x = std::round(area.x + QUEUE_MARGIN + k * QUEUE_SLOT_WIDTH + 2);
|
|
renderer.rect_draw({x, area.y + area.h - 2, QUEUE_SLOT_WIDTH - 4, 2},
|
|
k < count ? color : color_shade(color, SECONDARY_SHADE));
|
|
}
|
|
|
|
Fighter& queuer = game.sim.fighters[q];
|
|
Rect box = queuer.body.box;
|
|
Vec2 icon = {std::round(box.x + box.w / 2), std::round(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.sim.fighterCount || game.menu.isOpen)
|
|
return;
|
|
const Rules& rules = game.sim.match.queues[hoveredQueue].rules;
|
|
Vec2 size = game_summary_size(game, rules);
|
|
Vec2 mouse = game.input.mouse;
|
|
Rect panel = {mouse.x + TOOLTIP_OFFSET, mouse.y + TOOLTIP_OFFSET, size.x + TOOLTIP_PADDING * 2,
|
|
size.y + TOOLTIP_PADDING * 2};
|
|
panel.x = std::round(std::min(panel.x, CANVAS_WIDTH - panel.w));
|
|
panel.y = std::round(std::min(panel.y, CANVAS_HEIGHT - panel.h));
|
|
panel_draw(game.renderer, panel, game.color_get(hoveredQueue, true));
|
|
game_summary_draw(game, rules, {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING});
|
|
}
|