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:
1 parent
6003175173
commit
6f112ab231
54 files changed
+1150
-3046
No files matched your search
+324
-219
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user