HUD pass, weapon and trinket rework, and font arrows

HUD:
- Hotbars fit a quarter of the screen, dropping to small then tiny slots
  (thinner outlines when tiny), with cooldown bars matching their size.
- HUD elements are drawn on their own layer and composited at one
  opacity: hotbar slots are see-through until hovered, the rest fade
  while hovered.
- Rival windows fit the right column in at most two rows, shrinking
  icons then font, then dropping icons for name, health and ping bars.
- Tooltips number equipped items' slots and say Recoil for weapons that
  only push back. Previews use arrows, spread uses degrees.
- Crosshair fills in and shakes with charge, and weapons shake after
  firing until their cooldown ends. DPS and speed meters off by default.

Gameplay:
- Dual Wield stacks: each copy fires another recently held weapon from
  a fanned off-hand, at its own level's cut.
- Flamethrower: no slowing, smaller fireball with some recoil, and an
  airblast that knocks others away.
- Iron Weights slam on any landing faster than a plain fall (platforms
  too), stacking radius and damage, and speeding fast falls.
- Stuck arrows are one-way ledges until they fade, matching their
  (slightly larger) hit size. Attacks now collide with slopes.
- Halved Tesla Coil, Recoil Coil, Diazepam, Shock Absorber, Feather and
  screenshake values.

Art and tools:
- Weapon and trinket icons merged into one sheet each (normal, small,
  tiny), cells in catalog order. Grimoire wisps stand upright.
- Fonts gain the degree sign and four arrows, decoded from UTF-8.
- Menu fighter preview walks at quarter speed.
- Unequip from either catalog tab by right click or gamepad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 16:43:45 -05:00
1 parent 6003175173
commit 6f112ab231
54 files changed
+1150 -3046

No files matched your search

+324 -219
View File
@@ -3,7 +3,6 @@
#include <SDL3/SDL.h>
#include <algorithm>
constexpr int CHAMFER = 2;
constexpr float BANNER_SCALE = 4;
constexpr float BANNER_Y = 110;
constexpr float BAR_GAP = 2;
@@ -19,11 +18,17 @@ constexpr float RIVAL_OUT_ALPHA = 0.25f;
constexpr float SPECTATE_GAP = 4;
constexpr Control SPECTATE_CONTROLS[] = {CONTROL_LEFT, CONTROL_RIGHT, CONTROL_ATTACK};
constexpr float HUD_IDLE_ALPHA = 0.5f;
constexpr float HUD_HOVER_ALPHA = 0.5f;
constexpr float HUD_HOVER_FADE = 0.15f;
constexpr float RIVAL_PADDING = 2;
constexpr int RIVAL_COLUMNS_MAX = 3;
constexpr int RIVAL_FULL_SLOTS = 4;
constexpr int RIVALS_PER_ROW = 4;
// 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;
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;
@@ -36,8 +41,10 @@ constexpr float READY_GAP = 2;
constexpr float TIP_GAP = 4;
constexpr float COUNTDOWN_Y = 40;
constexpr float COUNTDOWN_DIM = 0.6f;
constexpr float CHEVRON_INSET = 3;
constexpr float CHEVRON_STEP = 3;
constexpr 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;
@@ -52,21 +59,20 @@ constexpr float SCORE_HOTBAR_GAP = 8;
constexpr float STATUS_BAR_HEIGHT = 12;
constexpr float STATUS_BAR_WIDTH = 80;
constexpr float STATUS_GAP = 6;
constexpr float PANEL_BORDER = 2;
// 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_HEIGHT = 7;
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_SPACING = 26;
constexpr float COOLDOWN_COMPACT_SPACING = 14;
constexpr float COOLDOWN_ROW_GAP = 2;
constexpr float COOLDOWN_SLIDE = 12;
constexpr int COOLDOWN_FULL_ROWS = 5;
constexpr float COOLDOWN_VISIBLE = 0.25f;
constexpr float OFFSCREEN_ARROW_SCALE = 2;
constexpr float OFFSCREEN_MARGIN = 8;
@@ -89,7 +95,7 @@ constexpr float PREVIEW_CAPTION_GAP = 8;
constexpr float PREVIEW_FEET = 120;
constexpr float PREVIEW_NAME_Y = 16;
constexpr float PREVIEW_SCALE = 3;
constexpr const char PREVIEW_ANIMATION[] = "Walk Right";
constexpr 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
@@ -114,7 +120,12 @@ constexpr float TRACK_DOT = 6;
constexpr float TRACK_HEIGHT = 2;
constexpr float TRACK_WIDTH = 200;
constexpr float SECONDS_PER_MINUTE = 60;
constexpr Vec2 LABEL_OFFSET = {4, 2};
// 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.",
@@ -155,6 +166,12 @@ constexpr const char* TIPS[] = {
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++)
@@ -194,6 +211,7 @@ void Ui::draw(Game& game)
// 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);
@@ -204,8 +222,8 @@ void Ui::draw(Game& game)
bool isShared = game.sim.match.state != MATCH_PRACTICE && game.local_count() > 1;
if (!isBarsRaised && !isShared && !isWatching)
{
hotbar_draw(game);
cooldowns_draw(game);
hotbar_draw(game);
}
if (isShared)
panels_draw(game);
@@ -283,12 +301,11 @@ void Ui::bars_draw(Game& game)
}
}
// A bordered bar filled from the left by fill, from 0 to 1.
void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color)
// 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);
renderer.rect_draw({bar.x + PANEL_BORDER, bar.y + PANEL_BORDER, std::round((bar.w - PANEL_BORDER * 2) * fill),
bar.h - PANEL_BORDER * 2},
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);
}
@@ -329,7 +346,9 @@ void Ui::match_draw(Game& game)
length += SDL_snprintf(text + length, sizeof(text) - length, i > 0 ? " - %d" : "%d", match.wins[i]);
Vec2 scale = {SCORE_SCALE, SCORE_SCALE};
float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x;
float hotbarRight = HOTBAR_MARGIN + game.local_get().weaponSlots * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP;
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++)
@@ -598,6 +617,7 @@ LoadoutLayout Ui::loadout_layout(Game& game, float top, float bottom)
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)
@@ -702,10 +722,10 @@ void Ui::loadout_draw(Game& game, float alpha, float top)
tooltip_text_draw(game, layout.tooltips[kind][i],
{.color = color, .position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}},
font);
icon_draw(game, isTrinket, item,
{.color = color,
.position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2,
panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}});
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}});
}
}
}
@@ -797,8 +817,10 @@ 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())
{
@@ -809,17 +831,16 @@ void Ui::status_draw(Game& game)
bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / maxHealth, tint);
SDL_snprintf(text, sizeof(text), "%d", (int)std::ceil(std::max(0.0f, fighter.health)));
float width = game.text.size_get(text, FONT_SMALL, DPS_SCALE).x;
game.text.draw(
game.renderer, text,
{.color = tint, .position = {std::round(bar.x - STATUS_GAP - width), y}, .scale = {DPS_SCALE, DPS_SCALE}});
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 width = game.text.size_get(label, FONT_SMALL, DPS_SCALE).x;
game.text.draw(game.renderer, label,
{.color = tint, .position = {std::round(right - width), y}, .scale = {DPS_SCALE, DPS_SCALE}});
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)
@@ -832,10 +853,12 @@ void Ui::status_draw(Game& game)
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. Hovering a filled slot outside the menu shows its tooltip.
// 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;
@@ -848,21 +871,22 @@ void Ui::hotbar_draw(Game& game)
int index = isTrinket ? i - fighter.weaponSlots : i;
int count = isTrinket ? fighter.trinketSlots : fighter.weaponSlots;
int item = isTrinket ? fighter.trinketLoadout[index] : fighter.weaponLoadout[index];
// In the HUD, slots are dimmed until the mouse comes over them; under the menu they're at full brightness.
float hover = slotHovers[isTrinket][index];
Slot slot = {
.rect = game.menu.slot_rect(isTrinket, index, count),
.alpha = game.menu.is_bars_open() ? 1 : HUD_IDLE_ALPHA + (1 - HUD_IDLE_ALPHA) * hover,
.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,
.isOpaque = true,
.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;
}
@@ -870,48 +894,51 @@ void Ui::hotbar_draw(Game& game)
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.
// 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 base = slot.isLit || isHovered || slot.isTarget ? tint : shade;
Color color = slot.isOpaque ? color_shade(base, slot.alpha) : color_fade(base, slot.alpha);
float fill = slot.isOpaque ? 1 : slot.alpha;
panel_draw(renderer, slot.rect, color, fill);
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)
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, fill));
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}});
{.color = color, .position = {slot.rect.x + slot.rect.w / 2, slot.rect.y + slot.rect.h / 2}},
slot.size);
Vec2 corner = {slot.rect.x + slot.rect.w - CHEVRON_INSET, slot.rect.y + slot.rect.h - CHEVRON_INSET};
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, "Chevron", 0,
{.color = color, .position = {corner.x, corner.y - i * CHEVRON_STEP}});
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)
if (slot.label <= 0 || slot.size == ICON_TINY)
return;
char label[] = {char('0' + slot.label), 0};
game.text.draw(renderer, label,
{.color = color, .position = {slot.rect.x + LABEL_OFFSET.x, slot.rect.y + LABEL_OFFSET.y}});
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.
@@ -920,6 +947,9 @@ 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)
@@ -976,36 +1006,42 @@ static void stat_format(char* text, int size, int row, const AttackSummary& summ
if (summary.spread > 0)
{
number_format(number, sizeof(number), summary.spread);
SDL_snprintf(text, size, "%s deg", number);
SDL_snprintf(text, size, "%s°", number);
}
break;
case 8:
case LUNGE_ROW:
if (summary.lunge != 0)
SDL_snprintf(text, size, summary.lunge > 0 ? "%g forward" : "%g back",
std::round(std::abs(summary.lunge)));
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. Values its trinkets change are marked; with a preview, ones the next level changes add "> next".
// 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 = LABELS[row]};
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 = attack_summary_get(fighter, weapon, level, isCharged);
AttackSummary& summary = summaries[column];
char* value = stat.values[column];
stat_format(value, STAT_LENGTH, row, summary, attack, isCharged);
bool changes[] = {summary.isPiecesChanged,
@@ -1021,10 +1057,9 @@ void Ui::stats_table_build(Fighter& fighter, int weapon, int level, bool isPrevi
if (isPreview && *value)
{
char next[STAT_LENGTH];
stat_format(next, sizeof(next), row, attack_summary_get(fighter, weapon, level + 1, isCharged), attack,
isCharged);
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);
SDL_snprintf(value + SDL_strlen(value), STAT_LENGTH - SDL_strlen(value), " → %s", next);
}
isShown |= *value != '\0';
}
@@ -1074,7 +1109,7 @@ void Ui::table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, cons
font);
}
// A trinket's effects at one level with the next level's after a ">", line by line. Where a line only changes in its
// 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)
@@ -1126,12 +1161,12 @@ static void effects_preview_get(Game& game, const char* now, const char* next, c
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]);
(wordsBefore[w] == wordsAfter[w] ? wordsBefore[w] : wordsBefore[w] + " → " + wordsAfter[w]);
else if (before.empty())
line = "> " + after;
line = "→ " + after;
else
{
line = before + " > " + after;
line = before + " → " + after;
if (game.text.size_get(line.c_str()).x > PREVIEW_LINE_MAX)
line = before + "\n> " + after;
}
@@ -1148,6 +1183,11 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o
tooltip.isTrinket = isTrinket;
tooltip.item = item;
Fighter& fighter = owner >= 0 ? game.sim.fighters[owner] : 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)
{
@@ -1159,7 +1199,7 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o
else
SDL_strlcpy(equipped, copies > 0 ? "Equipped" : "Not Equipped", sizeof(equipped));
tooltip.name = def.name;
// In the sandbox, the effects show what the next level changes after a ">", as weapons' stats do.
// 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)
@@ -1167,7 +1207,7 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o
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,
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);
@@ -1179,9 +1219,9 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o
const WeaponDef& def = WEAPON_DEFS[item];
tooltip.name = def.name;
// In the sandbox, values that change at the next level show it after a ">".
// 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,
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);
@@ -1237,10 +1277,11 @@ void Ui::tooltip_page_draw(Game& game, Page& page, Rect area, Tooltip& tooltip)
page_begin(game, page, area);
float iconY = area.y - page.scroll;
if (tooltip.item >= 0 && iconY >= area.y)
icon_draw(game, tooltip.isTrinket, tooltip.item,
{.color = tint,
.position = {area.x + area.w - (isScrolling ? SCROLL_WIDTH + TOOLTIP_PADDING : 0) - ICON_SIZE / 2,
iconY + ICON_SIZE / 2}});
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;;)
{
@@ -1293,32 +1334,43 @@ void Ui::tooltip_draw(Game& game, Tooltip& tooltip)
panel_draw(game.renderer, panel, tint);
tooltip_text_draw(game, tooltip, {.position = {panel.x + TOOLTIP_PADDING, panel.y + TOOLTIP_PADDING}});
if (tooltip.item >= 0)
icon_draw(game, tooltip.isTrinket, tooltip.item,
{.color = tint,
.position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2,
panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}});
tooltip_icon_draw(game, tooltip,
{.color = tint,
.position = {panel.x + panel.w - TOOLTIP_PADDING - ICON_SIZE / 2,
panel.y + TOOLTIP_PADDING + ICON_SIZE / 2}});
}
// Black box with a bordered, cut-cornered edge in the given color. The fill fades with the border unless given an
// alpha of its own.
void Ui::panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha)
// 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)
{
chamfer_draw(renderer, rect, border);
chamfer_draw(renderer,
{rect.x + PANEL_BORDER, rect.y + PANEL_BORDER, rect.w - PANEL_BORDER * 2, rect.h - PANEL_BORDER * 2},
color_fade(BLACK, fillAlpha >= 0 ? fillAlpha : border.a));
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});
}
// Fills a rect with its corners cut diagonally, one pixel per step.
void Ui::chamfer_draw(Renderer& renderer, Rect rect, Color color)
// 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)
{
for (int step = 0; step < CHAMFER; step++)
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 = CHAMFER - 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 + CHAMFER, rect.w, rect.h - CHAMFER * 2}, color);
renderer.rect_draw({rect.x, rect.y + cut, rect.w, rect.h - cut * 2}, color);
}
void Ui::widgets_begin(float alpha, bool isLive)
@@ -1496,7 +1548,7 @@ bool Ui::field_draw(Game& game, Rect box, char* text, int size)
text[--length] = '\0';
for (const char* character = input.typed; *character && length < size - 1; character++)
{
if (*character < GLYPH_FIRST || *character >= GLYPH_FIRST + GLYPH_COUNT)
if ((unsigned char)*character < GLYPH_FIRST || (unsigned char)*character >= GLYPH_TYPED_END)
continue;
text[length++] = *character;
text[length] = '\0';
@@ -1676,8 +1728,8 @@ void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color col
game.text.draw(game.renderer, name,
{.color = color, .position = {std::round(centre - nameSize.x / 2), area.y + PREVIEW_NAME_Y}});
Anm2& player = game.sprites.player;
Anm2Animation* walk = player.animation_get(PREVIEW_ANIMATION);
float frame = walk ? std::fmod(SDL_GetTicks() / 1000.0f * player.fps, (float)walk->length) : 0;
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)
@@ -1692,6 +1744,11 @@ void Ui::fighter_preview_draw(Game& game, Rect area, const char* name, Color col
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}};
@@ -1704,37 +1761,48 @@ static std::string ping_text_get(float ping)
float Ui::ping_width_get(Game& game, float ping)
{
std::string text = ping_text_get(ping);
float bars = PING_BARS * (PING_BAR_WIDTH + PING_BAR_GAP) - PING_BAR_GAP;
return bars + (text.empty() ? 0 : PING_BAR_GAP * 2 + game.text.size_get(text.c_str()).x);
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)
{
int lit = 0;
if (ping >= 0)
{
lit = PING_BARS;
for (float limit : PING_LIMITS)
lit -= ping >= limit;
lit = std::max(lit, 1);
}
Color color = lit > 0 ? PING_COLORS[lit - 1] : shade;
float x = std::round(corner.x);
float bottom = std::round(corner.y + LINE_HEIGHT - 3);
for (int i = 0; i < PING_BARS; i++)
{
float height = 2 + i * 2;
game.renderer.rect_draw({x, bottom - height, PING_BAR_WIDTH, height},
color_fade(i < lit ? color : color_shade(shade, 0.5f), alpha));
x += PING_BAR_WIDTH + PING_BAR_GAP;
}
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(color, alpha), .position = {x + PING_BAR_GAP, corner.y}});
{.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)
@@ -1887,8 +1955,7 @@ void Ui::update(Game& game)
hintAlpha = std::max(0.0f, hintAlpha - TICK / HINT_FADE);
spectate_update(game);
// The HUD's hotbar slots and the other fighters' windows brighten while the mouse is over them, and dim again
// after.
// 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);
@@ -1907,6 +1974,13 @@ void Ui::update(Game& game)
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
@@ -1938,60 +2012,50 @@ void Ui::update(Game& game)
bars_arrange(trinketBars);
}
// Every item cooling down shows at once, each on its own bar two slots long (whatever the hotbar's size) with its icon.
// Every bar cooling down, a row each: weapons downward from under the weapon hotbar, trinkets upward from over the
// trinket hotbar, each starting far enough off its hotbar that the icons (taller than the bars) clear its slots. Past a
// handful of one kind at once, its rows tighten and their icons shrink to half size so a full loadout still fits.
// 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)
{
float width = COOLDOWN_SLOTS * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP;
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;
int showing = 0;
for (int slot = 0; slot < SLOT_MAX; slot++)
showing += bars[slot].fade > 0;
bool isCompact = showing > COOLDOWN_FULL_ROWS;
float spacing = isCompact ? COOLDOWN_COMPACT_SPACING : COOLDOWN_SPACING;
float iconScale = isCompact ? MINI_ICON_SCALE : 1;
float clearance = std::max(0.0f, (ICON_SIZE * iconScale - COOLDOWN_BAR_HEIGHT) / 2);
for (int slot = 0; slot < SLOT_MAX; slot++)
{
CooldownBar& bar = bars[slot];
if (bar.fade <= 0 || bar.row < 0 || bar.item < 0)
continue;
float step = std::round(bar.row * spacing);
float y = isTrinket ? CANVAS_HEIGHT - HOTBAR_MARGIN - SLOT_SIZE - COOLDOWN_GAP - COOLDOWN_BAR_HEIGHT -
std::ceil(clearance) - step
: HOTBAR_MARGIN + SLOT_SIZE + COOLDOWN_GAP + std::ceil(clearance) + step;
cooldown_bar_draw(game, {HOTBAR_MARGIN, y, width, COOLDOWN_BAR_HEIGHT}, bar, isTrinket, bar.item,
iconScale);
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 bordered bar draining with the cooldown and its item's icon just right of it, at full size so its pixels stay
// whole. Both fade with the bar.
void Ui::cooldown_bar_draw(Game& game, Rect rect, CooldownBar& bar, bool isTrinket, int item, float iconScale)
{
Color color = color_fade(tint, bar.fade);
bar_draw(game.renderer, rect, bar.fill, color);
float icon = ICON_SIZE * iconScale;
icon_draw(game, isTrinket, item,
{.color = color,
.position = {std::round(rect.x + rect.w + COOLDOWN_GAP + icon / 2), rect.y + rect.h / 2},
.scale = {iconScale, iconScale}});
}
// A weapon's or trinket's HUD icon, centred on the placement.
void Ui::icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSize size)
{
Sprites& sprites = game.sprites;
Anm2* sheets[2][ICON_SIZE_COUNT] = {{&sprites.icons, &sprites.iconsSmall, &sprites.iconsTiny},
{&sprites.trinkets, &sprites.trinketsSmall, &sprites.trinketsTiny}};
Anm2& art = *sheets[isTrinket][size];
art.draw(game.renderer, isTrinket ? TRINKET_DEFS[item].name : WEAPON_DEFS[item].name, 0, placement);
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
@@ -2026,8 +2090,9 @@ void Ui::offscreen_draw(Game& game, Vec2 camera)
}
}
// Each of this screen's fighters' crosshairs in its colour: at the mouse for the keyboard's (and whenever the menu is
// open), otherwise where its stick aims. With none (a bot playing), the mouse's.
// 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;
@@ -2039,9 +2104,13 @@ void Ui::crosshairs_draw(Game& game, Vec2 camera)
isAny = true;
bool isMouse = (game.devices[i] == DEVICE_KEYBOARD && !input.devices[DEVICE_KEYBOARD].isAiming) ||
(i == game.local && game.menu.isOpen);
Vec2 aim = isMouse ? input.mouse : game.sim.fighters[i].controls.cursor - camera;
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)}});
{.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,
@@ -2194,16 +2263,21 @@ void Ui::spectate_draw(Game& game)
text_center_draw(game, text, y + LINE_HEIGHT, 1, shade);
}
// Lays out the other fighters' windows in rows of up to four along the bottom right (more rows stacking upwards, as
// even as they go), never left of the screen's middle (nor over the local hotbar and cooldowns). A window is as wide as
// its icon row, but no wider than one holding two weapons and two trinkets at full size: past that, or when the row
// doesn't fit, icons shrink to small, then tiny. Each kind wraps onto more rows past three across, and tiny icons wrap
// further if they still don't fit. Names and health use the small font, or the tiny one for every window if any doesn't
// fit.
// 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. 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;
Fighter& local = game.local_get();
int slots[2] = {};
for (int i = 0; i < game.sim.fighterCount; i++)
{
@@ -2215,57 +2289,63 @@ RivalLayout Ui::rivals_layout(Game& game)
}
if (layout.count == 0)
return layout;
int cardRows = (layout.count + RIVALS_PER_ROW - 1) / RIVALS_PER_ROW;
int perRow = (layout.count + cardRows - 1) / cardRows;
float step = SLOT_SIZE + SLOT_GAP;
float left =
std::max(CANVAS_WIDTH / 2.0f,
HOTBAR_MARGIN + std::max(local.trinketSlots * step, COOLDOWN_SLOTS * step + ICON_SIZE) + RIVAL_GAP);
float room = CANVAS_WIDTH - HOTBAR_MARGIN - left;
float 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;
auto icons_width = [&](int size, int columns) {
return columns * (ICON_PIXELS[size] + RIVAL_ICON_GAP) + RIVAL_DIVIDER - RIVAL_ICON_GAP;
};
float widthMax = icons_width(ICON_FULL, RIVAL_FULL_SLOTS) + inset * 2;
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 minimum = game.text.size_get(health, FONT_TINY).x + RIVAL_PADDING + RIVAL_BAR_MIN + inset * 2;
float width = 0;
for (int size = ICON_FULL; size < ICON_SIZE_COUNT; size++)
float height = 0;
int perRow = 1;
bool isFit = false;
for (int style = 0; style < RIVAL_STYLES && !isFit; style++)
{
// No more than a few of a kind side by side, wrapping onto more rows to keep to that; tiny icons can wrap
// further to fit.
int rowsMin = (std::max(slots[0], slots[1]) + RIVAL_COLUMNS_MAX - 1) / RIVAL_COLUMNS_MAX;
int rowsMax = size == ICON_TINY ? SLOT_MAX : rowsMin;
for (int rows = std::max(1, rowsMin); rows <= rowsMax; rows++)
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 = icons_width(size, layout.columns[0] + layout.columns[1]) + inset * 2;
width = std::ceil(std::max(icons, minimum));
layout.size = (IconSize)size;
layout.rows = rows;
bool isFit = icons <= widthMax && perRow * width + (perRow - 1) * RIVAL_GAP <= room;
if (isFit)
size = ICON_SIZE_COUNT;
if (size == ICON_SIZE_COUNT)
break;
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;
isFit = width <= HUD_COLUMN_WIDTH && cardRows <= RIVAL_ROWS_MAX &&
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);
}
float inner = width - inset * 2;
layout.font = game.sim.match.is_racing() || game.text.size_get(health).x + RIVAL_PADDING + RIVAL_BAR_MIN <= inner
? FONT_SMALL
: FONT_TINY;
for (int k = 0; k < layout.count; k++)
if (game.text.size_get(name_get(game, layout.fighters[k])).x > inner)
layout.font = FONT_TINY;
layout.line = game.text.line_height_get(layout.font);
float healthHeight = game.sim.match.is_racing() ? 0 : layout.line + RIVAL_PADDING;
float icons = layout.rows * (ICON_PIXELS[layout.size] + RIVAL_ICON_GAP) - RIVAL_ICON_GAP;
float height = inset + layout.line + RIVAL_PADDING + healthHeight + icons + inset;
for (int k = 0; k < layout.count; k++)
{
int row = k / perRow;
@@ -2277,10 +2357,11 @@ RivalLayout Ui::rivals_layout(Game& game)
return layout;
}
// Every other fighter's window in a match, bordered in its colour: its name centred, its health with a bar beside it,
// then its weapons, a divider, and its trinkets, the weapon in hand lit. A window is dimmed (a fighter out of the round
// more so) until the mouse comes over it, when it eases to full brightness. Hovering an
// icon shows the item's tooltip, as that fighter has it, in its colour.
// 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,
// see-through for a fighter out of the round, and fades while the mouse is over it. Hovering an icon shows the item's
// tooltip, as that fighter has it, in its colour.
void Ui::rivals_draw(Game& game)
{
Match& match = game.sim.match;
@@ -2299,41 +2380,63 @@ void Ui::rivals_draw(Game& game)
int index = layout.fighters[k];
Fighter& fighter = game.sim.fighters[index];
Rect card = layout.cards[k];
float idle = fighter.isAlive || match.is_racing() ? HUD_IDLE_ALPHA : RIVAL_OUT_ALPHA;
float alpha = index == spectated ? 1 : idle + (1 - idle) * rivalHovers[index];
// Faded by darkening toward the solid black fill, so the window stays opaque and nothing in it shows through
// anything else.
Color color = color_shade(game.color_get(index), alpha);
float base = fighter.isAlive || match.is_racing() || index == spectated ? 1 : RIVAL_OUT_ALPHA;
Color color = game.color_get(index);
Color dim = color_shade(color, SECONDARY_SHADE);
panel_draw(renderer, card, color, 1);
renderer.layer_begin();
panel_draw(renderer, card, color);
float y = card.y + inset;
Font font = layout.font;
const char* name = name_get(game, index);
Vec2 size = game.text.size_get(name, font);
game.text.draw(
renderer, name,
{.color = color,
.position = {std::round(card.x + (card.w - size.x) / 2), std::round(y + (layout.line - size.y) / 2)}},
font);
// 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;
if (!match.is_racing())
// 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;
game.text.draw(renderer, text,
{.color = color, .position = {x, std::round(y + (layout.line - size.y) / 2)}}, font);
x += size.x + RIVAL_PADDING;
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), card.x + card.w - inset - x, barHeight};
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++)
@@ -2366,6 +2469,7 @@ void Ui::rivals_draw(Game& game)
x += RIVAL_DIVIDER + RIVAL_ICON_GAP;
}
}
renderer.layer_end(base * hover_alpha_get(rivalHovers[index]));
}
if (hoveredFighter < 0 || game.menu.isOpen)
@@ -2375,6 +2479,7 @@ void Ui::rivals_draw(Game& game)
int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[hoveredSlot];
int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[hoveredSlot];
Tooltip tooltip = tooltip_build(game, isTrinket, item, level, hoveredFighter);
tooltip.slot = hoveredSlot;
// The tooltip takes that fighter's colour for the moment, so it's clear whose item it is.
Color colors[2] = {tint, shade};
tint = game.color_get(hoveredFighter);