From 5d6ebf2bbaf9ad9184bea29369fad3202b2a9e47 Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 16:57:11 -0500 Subject: [PATCH] Deal items on ready, /pvp, and calmer rival windows - Readying in a countdown deals any empty slots at random right away, and the countdown reads "Starting..." once everyone is ready. - /pvp toggles PvP in the sandbox, offline or in a lobby. - Rival windows sit at half opacity (half again when knocked out, and when hovered), show no item tooltips, and drop to small icons past two full-size rows. Co-Authored-By: Claude Opus 5.5 --- src/chat.cpp | 10 ++++++ src/match.cpp | 40 +++++++++++++++-------- src/match.h | 1 + src/ui.cpp | 90 +++++++++++++++++++++------------------------------ src/ui.h | 4 +-- 5 files changed, 77 insertions(+), 68 deletions(-) diff --git a/src/chat.cpp b/src/chat.cpp index d356d06..38d0255 100644 --- a/src/chat.cpp +++ b/src/chat.cpp @@ -238,8 +238,18 @@ void Chat::command(Game& game, const std::string& text) system("/color : Changes your colour. Colours: Red, Blue, Yellow, Green, Orange, Purple, Cyan, Pink, " "Lime, Teal, Magenta, Sky, Peach, Lavender, Brown, Silver."); system("/kick \"\" \"\": Removes a player from the lobby (host only)."); + system("/pvp: Turns PvP on or off in the sandbox."); return; } + if (name == "/pvp") + { + if (game.sim.match.state != MATCH_PRACTICE) + return system("PvP can only be changed in the sandbox."); + // Sent through this screen's controls like the menu's toggle, so every machine sees it on the same tick. + bool isPvp = !game.local_get().isPvp; + game.input.edit_send(game.devices[game.local], edit_encode({.type = EDIT_PVP, .value = isPvp})); + return system(isPvp ? "PvP is now on." : "PvP is now off."); + } if (name == "/kick") { if (words.size() < 2) diff --git a/src/match.cpp b/src/match.cpp index 8420c5b..2b71801 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -140,14 +140,17 @@ void Match::update(Sim& sim) break; case MATCH_COUNTDOWN: { - // Ready sticks once pressed, and bots always are. Once everyone is, the wait drops to its last second. - // Fighters choosing items pick them through their controls. + // Ready sticks once pressed, and bots always are. Readying deals any slots still empty at random. Once + // everyone is, the wait drops to its last second. Fighters choosing items pick them through their controls. bool isEveryone = true; for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.fighters[i]; edit_take(sim, fighter); + bool wasReady = isReady[i]; isReady[i] |= fighter.isBot || fighter.is_down(CONTROL_READY); + if (isReady[i] && !wasReady) + loadout_deal(sim, i); isEveryone &= isReady[i]; } if (isEveryone) @@ -421,20 +424,31 @@ void Match::mirror_copy(Sim& sim, bool isTrinket) } } -// The countdown is over: slots still empty after choosing are dealt at random (once, and shared, when mirrored), and -// the menu closes. +// Deals a fighter's empty slots at random. A mirrored kind is dealt only to the first fighter, and copied to everyone. +void Match::loadout_deal(Sim& sim, int index) +{ + Fighter& fighter = sim.fighters[index]; + for (int kind = 0; kind < 2; kind++) + { + bool isTrinket = kind == 1; + ItemRules& items = isTrinket ? rules.trinkets : rules.weapons; + if (items.isMirror && index > 0) + continue; + if (isTrinket) + picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, items.isDuplicates); + else + picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, items.isDuplicates); + if (items.isMirror) + mirror_copy(sim, isTrinket); + } +} + +// The countdown is over: slots still empty after choosing (by anyone who never readied) are dealt at random, and the +// menu closes. void Match::fight_start(Sim& sim) { for (int i = 0; i < sim.fighterCount; i++) - { - Fighter& fighter = sim.fighters[i]; - picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, rules.weapons.isDuplicates); - picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, rules.trinkets.isDuplicates); - if (i == 0 && rules.weapons.isMirror) - mirror_copy(sim, false); - if (i == 0 && rules.trinkets.isMirror) - mirror_copy(sim, true); - } + loadout_deal(sim, i); if (isWeaponPick || isTrinketPick) sim.cues->choice_close(); isWeaponPick = false; diff --git a/src/match.h b/src/match.h index d8014a0..eb28564 100644 --- a/src/match.h +++ b/src/match.h @@ -248,6 +248,7 @@ private: void fight_start(Sim& sim); void edit_take(Sim& sim, Fighter& fighter); void mirror_copy(Sim& sim, bool isTrinket); + void loadout_deal(Sim& sim, int fighter); void deathmatch_update(Sim& sim); void race_update(Sim& sim); void queues_update(Sim& sim); diff --git a/src/ui.cpp b/src/ui.cpp index 475e1ec..4f17abf 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -14,7 +14,8 @@ constexpr float RIVAL_BAR_MIN = 16; constexpr float RIVAL_DIVIDER = 1; constexpr float RIVAL_GAP = 4; constexpr float RIVAL_ICON_GAP = 2; -constexpr float RIVAL_OUT_ALPHA = 0.25f; +constexpr float RIVAL_ALPHA = 0.5f; +constexpr float RIVAL_OUT_SHARE = 0.5f; constexpr float SPECTATE_GAP = 4; constexpr Control SPECTATE_CONTROLS[] = {CONTROL_LEFT, CONTROL_RIGHT, CONTROL_ATTACK}; constexpr float HUD_IDLE_ALPHA = 0.5f; @@ -25,6 +26,8 @@ constexpr int RIVAL_COLUMNS_MAX = 3; // What the other fighters' windows try, from largest to smallest, until they all fit in a couple of rows: shrinking // icons before the font, then leaving the icons out. constexpr int RIVAL_ROWS_MAX = 2; +// The most rows of full-size icons the windows can stack up between them before the icons shrink. +constexpr int RIVAL_FULL_ICON_ROWS_MAX = 2; constexpr int RIVAL_STYLES = 6; constexpr int RIVAL_ICON_STYLES = 4; constexpr Font RIVAL_FONTS[RIVAL_STYLES] = {FONT_SMALL, FONT_SMALL, FONT_TINY, FONT_TINY, FONT_SMALL, FONT_TINY}; @@ -393,7 +396,7 @@ void Ui::match_draw(Game& game) if (game.menu.isOpen) break; - SDL_snprintf(text, sizeof(text), "%s %d", info.round, info.seconds); + SDL_snprintf(text, sizeof(text), "%s %s", info.round, info.time); text_center_draw(game, text, y, ROUND_SCALE); y += game.text.line_height_get(FONT_SMALL, ROUND_SCALE) + READY_GAP; SDL_snprintf(text, sizeof(text), isLocalReady ? "%d/%d Ready" : "%d/%d Ready - Press %s to Ready Up", @@ -731,7 +734,8 @@ void Ui::loadout_draw(Game& game, float alpha, float top) } // Text centred across the canvas with its top at y. -// What the countdown shows: the round's name, whole seconds left, how many are ready, and the key that readies up. +// What the countdown shows: the round's name, whole seconds left (or Starting... once everyone's ready), how many are +// ready, and the key that readies up. static CountdownInfo countdown_info_get(Game& game, bool isPad) { Match& match = game.sim.match; @@ -742,9 +746,12 @@ static CountdownInfo countdown_info_get(Game& game, bool isPad) SDL_strlcpy(info.round, "Final Round", sizeof(info.round)); else SDL_snprintf(info.round, sizeof(info.round), "Round %d", match.round + 1); - info.seconds = (int)std::ceil(match.timer); for (int i = 0; i < game.sim.fighterCount; i++) info.ready += match.isReady[i]; + if (info.ready == game.sim.fighterCount) + SDL_strlcpy(info.time, "Starting...", sizeof(info.time)); + else + SDL_snprintf(info.time, sizeof(info.time), "%d", (int)std::ceil(match.timer)); Binding binding = game.input.bindings[CONTROL_READY]; game.input.binding_name_get(isPad ? binding.pad : binding.key, isPad, info.key, sizeof(info.key)); return info; @@ -758,7 +765,6 @@ void Ui::countdown_aside_draw(Game& game, bool isPad) { CountdownInfo info = countdown_info_get(game, isPad); const char* round = info.round; - int seconds = info.seconds; int ready = info.ready; const char* key = game.sim.match.isReady[game.local] ? nullptr : info.key; float margin = (CANVAS_WIDTH - MENU_WIDTH) / 2; @@ -771,16 +777,20 @@ void Ui::countdown_aside_draw(Game& game, bool isPad) return game.text.line_height_get(FONT_SMALL, scale); }; - float labelScale = game.text.size_get(round, FONT_SMALL, ASIDE_LABEL_SCALE).x <= margin - ASIDE_PADDING * 2 - ? ASIDE_LABEL_SCALE - : 1; - float height = - game.text.line_height_get(FONT_SMALL, labelScale) + game.text.line_height_get(FONT_SMALL, ROUND_SCALE); + // Each line at the largest size that fits the margin. + auto scale_fit = [&](const char* line, float scale) { + for (; scale > 1; scale--) + if (game.text.size_get(line, FONT_SMALL, scale).x <= margin - ASIDE_PADDING * 2) + break; + return scale; + }; + float labelScale = scale_fit(round, ASIDE_LABEL_SCALE); + float timeScale = scale_fit(info.time, ROUND_SCALE); + float height = game.text.line_height_get(FONT_SMALL, labelScale) + game.text.line_height_get(FONT_SMALL, timeScale); float y = middle - height / 2; y += line_draw(round, margin / 2, y, labelScale); + line_draw(info.time, margin / 2, y, timeScale); char text[TOOLTIP_LENGTH]; - SDL_snprintf(text, sizeof(text), "%d", seconds); - line_draw(text, margin / 2, y, ROUND_SCALE); float rightX = CANVAS_WIDTH - margin / 2; y = middle - (key ? LINE_HEIGHT * 3 : LINE_HEIGHT) / 2; @@ -1177,12 +1187,12 @@ static void effects_preview_get(Game& game, const char* now, const char* next, c // Name, numbers at a level and description of a weapon or trinket. Trinkets say how many copies are equipped, and // when they stack. -Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int owner) +Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level) { Tooltip tooltip; tooltip.isTrinket = isTrinket; tooltip.item = item; - Fighter& fighter = owner >= 0 ? game.sim.fighters[owner] : game.local_get(); + Fighter& fighter = game.local_get(); int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; int slots = isTrinket ? fighter.trinketSlots : fighter.weaponSlots; tooltip.slot = std::find(loadout, loadout + slots, item) - loadout; @@ -2271,10 +2281,11 @@ static float rival_ping_get(Game& game, int fighter) // 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. +// or health (with its connection's bars, online). Without icons, the health number can go to make room. Full-size +// icons stack no more than two rows deep across the windows. When they don't all fit, the icons shrink to small, then +// the font to tiny, then the icons to tiny, then the icons are left out. Each kind of icon wraps onto more rows past +// three across, and tiny icons wrap further if they still don't fit. If even names and health alone don't fit, the +// windows split the column evenly and the names are cut short. RivalLayout Ui::rivals_layout(Game& game) { RivalLayout layout; @@ -2336,7 +2347,8 @@ RivalLayout Ui::rivals_layout(Game& game) 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 && + bool isFullTall = layout.size == ICON_FULL && cardRows * layout.rows > RIVAL_FULL_ICON_ROWS_MAX; + isFit = width <= HUD_COLUMN_WIDTH && cardRows <= RIVAL_ROWS_MAX && !isFullTall && cardRows * (height + RIVAL_GAP) - RIVAL_GAP <= room; } } @@ -2359,9 +2371,8 @@ RivalLayout Ui::rivals_layout(Game& game) // 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. +// weapon in hand lit. Each is laid over the game as one piece, half see-through (half again for a fighter out of the +// round), and fades by half again while the mouse is over it. void Ui::rivals_draw(Game& game) { Match& match = game.sim.match; @@ -2372,15 +2383,12 @@ void Ui::rivals_draw(Game& game) float inset = PANEL_BORDER + RIVAL_PADDING; float iconSize = ICON_PIXELS[layout.size]; float cell = iconSize + RIVAL_ICON_GAP; - int hoveredFighter = -1; - int hoveredKind = 0; - int hoveredSlot = 0; for (int k = 0; k < layout.count; k++) { int index = layout.fighters[k]; Fighter& fighter = game.sim.fighters[index]; Rect card = layout.cards[k]; - float base = fighter.isAlive || match.is_racing() || index == spectated ? 1 : RIVAL_OUT_ALPHA; + float base = RIVAL_ALPHA * (fighter.isAlive || match.is_racing() || index == spectated ? 1 : RIVAL_OUT_SHARE); Color color = game.color_get(index); Color dim = color_shade(color, SECONDARY_SHADE); renderer.layer_begin(); @@ -2446,21 +2454,13 @@ void Ui::rivals_draw(Game& game) int* loadout = isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout; for (int slot = 0; slot < slots; slot++) { - Rect box = {x + slot % layout.columns[kind] * cell, y + slot / layout.columns[kind] * cell, iconSize, - iconSize}; int item = loadout[slot]; if (item < 0) continue; bool isLit = isTrinket || slot == fighter.slot; - icon_draw(game, isTrinket, item, - {.color = isLit ? color : dim, .position = {box.x + box.w / 2, box.y + box.h / 2}}, - layout.size); - if (is_point_in_rect(game.input.mouse, box)) - { - hoveredFighter = index; - hoveredKind = kind; - hoveredSlot = slot; - } + Vec2 centre = {x + slot % layout.columns[kind] * cell + iconSize / 2, + y + slot / layout.columns[kind] * cell + iconSize / 2}; + icon_draw(game, isTrinket, item, {.color = isLit ? color : dim, .position = centre}, layout.size); } x += layout.columns[kind] * cell; if (kind == 0) @@ -2471,22 +2471,6 @@ void Ui::rivals_draw(Game& game) } renderer.layer_end(base * hover_alpha_get(rivalHovers[index])); } - - if (hoveredFighter < 0 || game.menu.isOpen) - return; - Fighter& fighter = game.sim.fighters[hoveredFighter]; - bool isTrinket = hoveredKind == 1; - int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[hoveredSlot]; - int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[hoveredSlot]; - Tooltip tooltip = tooltip_build(game, isTrinket, item, level, hoveredFighter); - 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); - shade = color_shade(tint, SECONDARY_SHADE); - tooltip_draw(game, tooltip); - tint = colors[0]; - shade = colors[1]; } // Choosing items with several players on this screen: a panel each below top (side by side for two, a 2x2 grid for diff --git a/src/ui.h b/src/ui.h index 02d7934..dc89c30 100644 --- a/src/ui.h +++ b/src/ui.h @@ -79,7 +79,7 @@ struct RivalLayout struct CountdownInfo { char round[64] = {}; - int seconds = 0; + char time[16] = {}; int ready = 0; char key[64] = {}; }; @@ -198,7 +198,7 @@ public: void draw(Game& game); void slot_draw(Game& game, Slot slot); void panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha = -1, float stroke = PANEL_BORDER); - Tooltip tooltip_build(Game& game, bool isTrinket, int item, int level, int owner = -1); + Tooltip tooltip_build(Game& game, bool isTrinket, int item, int level); void stats_table_build(Fighter& fighter, int weapon, int level, bool isPreview, Tooltip& tooltip); void table_row_draw(Game& game, Tooltip& tooltip, int row, Vec2 corner, const float* columns, Font font, float alpha);