From 9a41474069ad55576cc762fa07b63787558f3448 Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 18:11:06 -0500 Subject: [PATCH] Tomatoes, match tabs, shared pings, and a round of fixes - On the results, everyone but the winner splats tomatoes where they click (bots aim at the winner), with a new splat sound. - During a match the menu has the item tabs (choosing when the countdown allows, and otherwise levelling the sandbox's levels for when you're back), Play (the game's rules) and Settings. - Pings are shared through the server (protocol 4), so other players' connection bars show over their heads and on their windows. - PvP comes into lobbies with your profile and is saved. - The choice menu opens by itself however the countdown began. - Knocked-out fighters keep their buttons current, so Ready on the results toggles once per press. - The weapon wheel wraps both ways. Readying with empty slots plays the equip sound for your own fighter. - Shock Absorber: up to 50% less weapon movement and 33% less knockback. Only the catalog previews the next level. Co-Authored-By: Claude Opus 5.5 --- resources/sfx/SOURCES.md | 5 +- resources/sfx/splat.ogg | Bin 0 -> 5218 bytes server/server.cpp | 6 +++ src/audio.cpp | 1 + src/audio.h | 1 + src/bot.cpp | 15 ++++++ src/effects.cpp | 32 ++++++++++++ src/effects.h | 14 +++++ src/fighter.cpp | 4 ++ src/input.cpp | 15 +++--- src/input.h | 3 +- src/main.cpp | 6 +-- src/match.cpp | 48 ++++++++++++----- src/match.h | 6 ++- src/menu.cpp | 108 +++++++++++++++++++++++++++++++-------- src/menu.h | 7 +++ src/protocol.h | 8 +-- src/scene.cpp | 19 ++++--- src/scene.h | 4 +- src/session.cpp | 21 +++++++- src/session.h | 4 ++ src/settings.cpp | 15 ++++-- src/settings.h | 1 + src/sim.h | 4 +- src/trinket.h | 9 ++-- src/ui.cpp | 14 ++--- src/ui.h | 2 +- tools/sfx_build.py | 1 + tools/sfx_loudness.json | 1 + 29 files changed, 295 insertions(+), 79 deletions(-) create mode 100644 resources/sfx/splat.ogg diff --git a/resources/sfx/SOURCES.md b/resources/sfx/SOURCES.md index 52b80ce..6421343 100644 --- a/resources/sfx/SOURCES.md +++ b/resources/sfx/SOURCES.md @@ -14,7 +14,7 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | break | US02-96-1 CRASH, MISCELLANEOUS - SMALL OBJECTS FALLING ON CEMENT | 0.59s | 1x | 11025 | 2808 | | cast | synth ember | | 1x | 11025 | 2556 | | charge | synth warp | | 1x | 11025 | 5076 | -| chat | synth beeps | | 1x | 11025 | 567 | +| chat | synth beeps | | 1x | 11025 | 576 | | crawl | kept from the synth set | | | | | | dash | Swish 4003_47_4 | 0.17s | 1x | 11025 | 1143 | | death | US03-34-1 KICK, HUMAN - SINGLE KICK INTO STOMACH | 0.17s | 1x | 11025 | 2916 | @@ -86,6 +86,7 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | slam | US03-39-2 METAL, HIT - LOUD METAL CLUNK | 0.16s | 0.7x | 11025 | 3852 | | slash | Swish 4003_47_2 | 0.79s | 1x | 11025 | 1170 | | slash_heavy | Swish 4003_47_2 | 0.79s | 0.75x | 11025 | 1908 | +| splat | CartoonSplat 4001_32_1 | first | 1x | 11025 | 1116 | | step | CartoonFootsteps 4003_46_2 | 0.80s | 1x | 16000 | 891 | | swap | kept from the demo1 set | | | | | | swing | Swish 4003_47_1 | 0.40s | 1x | 11025 | 1098 | @@ -102,4 +103,4 @@ Cut from the Sound Ideas Series 4000 and Guns libraries and the Universal Studio | wisp | US02-45-5 ARCHERY - SHOOT FLAMING ARROW | 0.18s | 1x | 11025 | 1584 | | zap | US03-12-1 ELECTRICITY - SMALL TRANSFORMER ARCS | 0.48s | 1x | 11025 | 1296 | -The built sounds come to 144 KB as BRR. +The built sounds come to 145 KB as BRR. diff --git a/resources/sfx/splat.ogg b/resources/sfx/splat.ogg new file mode 100644 index 0000000000000000000000000000000000000000..db8e8294ea7783236899d24376c8ef7e22bb1f4e GIT binary patch literal 5218 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 0; i++) + { + Fighter& fighter = sim.fighters[i]; + if (!fighter.isBot || i == match.winner) + continue; + Vec2 miss = {sim.rng.random_get(-TOMATO_MISS, TOMATO_MISS), sim.rng.random_get(-TOMATO_MISS, TOMATO_MISS)}; + bool isThrowing = sim.rng.random_get(0, 1) < TOMATO_CHANCE; + fighter.controls = {.cursor = sim.fighters[match.winner].center_get() + miss, + .buttons = (Uint16)(isThrowing << CONTROL_ATTACK)}; + } for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.fighters[i]; diff --git a/src/effects.cpp b/src/effects.cpp index 17c5721..a59a0fd 100644 --- a/src/effects.cpp +++ b/src/effects.cpp @@ -14,6 +14,12 @@ constexpr float FLASH_LIFE = 0.05f; constexpr float FLASH_WIDTH = 6; constexpr float RING_LIFE = 0.3f; constexpr int RING_WIDTH = 2; +constexpr float SPLAT_CORE = 6; +constexpr float SPLAT_FADE = 0.6f; +constexpr float SPLAT_LIFE = 2; +constexpr float SPLAT_REACH = 10; +constexpr float SPLAT_SIZE_MAX = 3.5f; +constexpr float SPLAT_SIZE_MIN = 1.5f; constexpr float AFTERIMAGE_LIFE = 0.12f; constexpr float NUMBER_GAP = 8; constexpr float POPUP_FADE = 0.4f; @@ -127,6 +133,19 @@ void Effects::ring_spawn(Vec2 position, float radius, Color color) effect_add(rings, Ring{color, position, RING_LIFE, radius}); } +// A big blob where it landed, with smaller ones flung round it. +void Effects::splat_spawn(Vec2 position, Color color) +{ + Splat splat = {.color = color, .position = position, .life = SPLAT_LIFE}; + splat.sizes[0] = SPLAT_CORE; + for (int i = 1; i < SPLAT_BLOBS; i++) + { + splat.blobs[i] = vec2_polar(random_get(0, 2 * PI), random_get(SPLAT_CORE, SPLAT_REACH)); + splat.sizes[i] = random_get(SPLAT_SIZE_MIN, SPLAT_SIZE_MAX); + } + effect_add(splats, splat); +} + void Effects::dps_record(int amount) { hits.push_back({ticks * TICK, amount}); @@ -168,6 +187,10 @@ void Effects::update() ring.life -= TICK; std::erase_if(rings, [](Ring& ring) { return ring.life <= 0; }); + for (Splat& splat : splats) + splat.life -= TICK; + std::erase_if(splats, [](Splat& splat) { return splat.life <= 0; }); + for (Afterimage& afterimage : afterimages) afterimage.life -= TICK; std::erase_if(afterimages, [](Afterimage& afterimage) { return afterimage.life <= 0; }); @@ -209,6 +232,14 @@ void Effects::draw(Game& game) renderer.line_draw(flash.position - across, flash.position + across, width * FLASH_CROSS, flash.color); } + // Each splat holds, then fades out over its last moments. + for (Splat& splat : splats) + { + Color color = color_fade(splat.color, std::min(1.0f, splat.life / SPLAT_FADE)); + for (int i = 0; i < SPLAT_BLOBS; i++) + renderer.circle_draw(splat.position + splat.blobs[i], splat.sizes[i], color); + } + // Each ring eases out to its full radius as it fades. for (Ring& ring : rings) { @@ -259,5 +290,6 @@ void Effects::clear() particles.clear(); popups.clear(); rings.clear(); + splats.clear(); shake = 0; } diff --git a/src/effects.h b/src/effects.h index 0ddcc56..04d7911 100644 --- a/src/effects.h +++ b/src/effects.h @@ -58,6 +58,18 @@ struct Ring float radius = 0; }; +constexpr int SPLAT_BLOBS = 6; + +// A tomato's splat: blobs of colour round where it landed, fading out. +struct Splat +{ + Color color; + Vec2 position; + Vec2 blobs[SPLAT_BLOBS]; + float sizes[SPLAT_BLOBS]; + float life = 0; +}; + // A fading copy of a sprite left behind by something moving fast. struct Afterimage { @@ -73,6 +85,7 @@ public: void afterimage_spawn(Pose pose); void flash_spawn(Vec2 position, float angle, float size, Color color); void ring_spawn(Vec2 position, float radius, Color color); + void splat_spawn(Vec2 position, Color color); void afterimage_draw(Game& game); void number_spawn(Vec2 position, int value, int target, Color color); void dps_record(int amount); @@ -91,6 +104,7 @@ private: std::vector particles; std::vector popups; std::vector rings; + std::vector splats; float shake = 0; int ticks = 0; }; diff --git a/src/fighter.cpp b/src/fighter.cpp index 4fe8101..d067052 100644 --- a/src/fighter.cpp +++ b/src/fighter.cpp @@ -154,8 +154,12 @@ void Fighter::damage_take(Sim& sim, Damage damage) void Fighter::update(Sim& sim) { + // Out of the round, it still keeps track of its buttons, so a held one (like Ready on the results) counts once. if (!isAlive) + { + buttonsPrevious = controls.buttons; return; + } cornerPrevious = {body.box.x, body.box.y}; flash = std::max(0.0f, flash - FLASH_DECAY * TICK); shockCooldown -= TICK; diff --git a/src/input.cpp b/src/input.cpp index 8052efa..8031f9b 100644 --- a/src/input.cpp +++ b/src/input.cpp @@ -350,14 +350,14 @@ void Input::edit_send(int device, int edit) source.edits[source.editCount++] = edit; } -// A device's bound keys, buttons and sticks as one tick of fighter controls, aiming from origin, with the oldest edit -// and any Ready click waiting. Number keys (on the keyboard), next and previous weapon step from the fighter's current -// slot, and the fighter wraps it into range. With the menu open, only Ready and picks get through. -Controls Input::controls_get(int device, Vec2 camera, Vec2 origin, int slot, bool isMenuOpen) +// A device's bound keys, buttons and sticks as one tick of a fighter's controls, aiming from its middle, with the +// oldest edit and any Ready click waiting. Number keys (on the keyboard) pick a slot, and next and previous weapon step +// from the fighter's current one, wrapping round its slots. With the menu open, only Ready and picks get through. +Controls Input::controls_get(int device, Vec2 camera, Fighter& fighter, bool isMenuOpen) { Device& source = devices[device]; bool isKeyboard = device == DEVICE_KEYBOARD; - Controls controls = {.cursor = aim_get(device, camera, origin), + Controls controls = {.cursor = aim_get(device, camera, fighter.center_get()), .edit = source.editCount > 0 ? source.edits[0] : -1, .slot = isMenuOpen || !isKeyboard ? -1 : slotPressed}; for (int i = 0; i < CONTROL_COUNT; i++) @@ -371,10 +371,11 @@ Controls Input::controls_get(int device, Vec2 camera, Vec2 origin, int slot, boo source.isReady = false; if (isMenuOpen) return controls; + int slots = fighter.weaponSlots; if (is_control_down(device, CONTROL_NEXT_WEAPON) && !source.previous[CONTROL_NEXT_WEAPON]) - controls.slot = slot + 1; + controls.slot = (fighter.slot + 1) % slots; if (is_control_down(device, CONTROL_PREVIOUS_WEAPON) && !source.previous[CONTROL_PREVIOUS_WEAPON]) - controls.slot = slot - 1; + controls.slot = (fighter.slot - 1 + slots) % slots; return controls; } diff --git a/src/input.h b/src/input.h index c3023de..e02bc53 100644 --- a/src/input.h +++ b/src/input.h @@ -3,6 +3,7 @@ #include "common.h" #include +class Fighter; struct Game; struct SDL_Gamepad; struct SDL_Joystick; @@ -159,7 +160,7 @@ public: void poll(Game& game); void tick_end(); void bindings_reset(); - Controls controls_get(int device, Vec2 camera, Vec2 origin, int slot, bool isMenuOpen); + Controls controls_get(int device, Vec2 camera, Fighter& fighter, bool isMenuOpen); void edit_send(int device, int edit); Vec2 aim_get(int device, Vec2 camera, Vec2 origin); bool is_down(int scancode); diff --git a/src/main.cpp b/src/main.cpp index 49a4cb3..42d1c40 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -144,8 +144,7 @@ static bool frame_run(bool isDrawn = true) { Fighter& own = game.local_get(); bool isHeld = game.menu.isOpen || game.chat.isOpen || session.is_watching(); - Controls controls = - game.input.controls_get(DEVICE_KEYBOARD, game.camera, own.center_get(), own.slot, isHeld); + Controls controls = game.input.controls_get(DEVICE_KEYBOARD, game.camera, own, isHeld); session.update(game, controls); game.input.tick_end(); if (session.isViewChanged) @@ -161,8 +160,7 @@ static bool frame_run(bool isDrawn = true) Fighter& fighter = game.sim.fighters[i]; bool isMenuOpen = game.menu.isOpen && i == game.local; if (game.is_local(i)) - fighter.controls = game.input.controls_get(game.devices[i], game.camera, fighter.center_get(), - fighter.slot, isMenuOpen); + fighter.controls = game.input.controls_get(game.devices[i], game.camera, fighter, isMenuOpen); } game.rollback.update(game); presentation_update(game); diff --git a/src/match.cpp b/src/match.cpp index 2b71801..aaef1ac 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -4,6 +4,7 @@ #include #include +constexpr float TOMATO_COOLDOWN = 0.25f; constexpr Burst FINISH_BURST = {.gravity = 200, .life = 1, .size = 3, .speed = 240, .count = 40}; constexpr float RACE_GRACE = 15; constexpr float ROUND_OVER_TIME = 2.5f; @@ -45,9 +46,11 @@ Rules rules_clean(Rules rules) return rules; } -// Fills a loadout's empty slots with random picks out of count items, none repeating what's already in it. -static void picks_deal(Rng& rng, int* loadout, int slots, int count, bool isDuplicates) +// Fills a loadout's empty slots with random picks out of count items, none repeating what's already in it. Returns +// whether it filled any. +static bool picks_deal(Rng& rng, int* loadout, int slots, int count, bool isDuplicates) { + bool isDealt = false; for (int i = 0; i < slots; i++) { if (loadout[i] >= 0) @@ -56,7 +59,9 @@ static void picks_deal(Rng& rng, int* loadout, int slots, int count, bool isDupl while (!isDuplicates && std::find(loadout, loadout + slots, pick) != loadout + slots) pick = rng.random_get(0, count); loadout[i] = pick; + isDealt = true; } + return isDealt; } // Whether a round hands out this kind of item: the first round of the match always, every round, or every period. @@ -93,6 +98,8 @@ void Match::update(Sim& sim) { case MATCH_PRACTICE: case MATCH_OVER: + if (state == MATCH_OVER) + tomatoes_update(sim); // Online, after a queued game, Ready says to play it again, and once every player still in it is set, it // counts up to the server starting afresh with them. if (state == MATCH_OVER && isOnline) @@ -149,8 +156,8 @@ void Match::update(Sim& sim) 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); + if (isReady[i] && !wasReady && loadout_deal(sim, i)) + sim.cues->deal_play(i); isEveryone &= isReady[i]; } if (isEveryone) @@ -316,8 +323,6 @@ void Match::round_start(Sim& sim, bool isFirst) mirror_copy(sim, true); isWeaponPick &= rules.weapons.isChoice; isTrinketPick &= rules.trinkets.isChoice; - if (isWeaponPick || isTrinketPick) - sim.cues->choice_open(); std::fill(std::begin(isReady), std::end(isReady), false); std::fill(std::begin(finishTimes), std::end(finishTimes), -1.0f); @@ -410,6 +415,20 @@ void Match::edit_take(Sim& sim, Fighter& fighter) } } +// On the results, everyone but the winner can splat tomatoes where they click, for show, a few a second at most. +void Match::tomatoes_update(Sim& sim) +{ + for (int i = 0; i < sim.fighterCount; i++) + { + Fighter& fighter = sim.fighters[i]; + tomatoCooldowns[i] -= TICK; + if (i == winner || isGone[i] || tomatoCooldowns[i] > 0 || !fighter.is_pressed(CONTROL_ATTACK)) + continue; + tomatoCooldowns[i] = TOMATO_COOLDOWN; + sim.cues->tomato_splat(fighter.controls.cursor, i); + } +} + // Copies the first fighter's loadout of one kind of item to every other fighter. void Match::mirror_copy(Sim& sim, bool isTrinket) { @@ -424,10 +443,12 @@ void Match::mirror_copy(Sim& sim, bool isTrinket) } } -// 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) +// Deals a fighter's empty slots at random, returning whether it filled any. A mirrored kind is dealt only to the +// first fighter, and copied to everyone. +bool Match::loadout_deal(Sim& sim, int index) { Fighter& fighter = sim.fighters[index]; + bool isDealt = false; for (int kind = 0; kind < 2; kind++) { bool isTrinket = kind == 1; @@ -435,12 +456,15 @@ void Match::loadout_deal(Sim& sim, int index) if (items.isMirror && index > 0) continue; if (isTrinket) - picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, items.isDuplicates); + isDealt |= + 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); + isDealt |= + picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, items.isDuplicates); if (items.isMirror) mirror_copy(sim, isTrinket); } + return isDealt; } // The countdown is over: slots still empty after choosing (by anyone who never readied) are dealt at random, and the @@ -449,8 +473,6 @@ void Match::fight_start(Sim& sim) { for (int i = 0; i < sim.fighterCount; i++) loadout_deal(sim, i); - if (isWeaponPick || isTrinketPick) - sim.cues->choice_close(); isWeaponPick = false; isTrinketPick = false; clock = 0; @@ -788,6 +810,7 @@ Profile profile_get(Fighter& fighter) std::copy(std::begin(fighter.trinketSlotLevels), std::end(fighter.trinketSlotLevels), profile.trinketSlotLevels); std::copy(std::begin(fighter.weaponItemLevels), std::end(fighter.weaponItemLevels), profile.weaponItemLevels); std::copy(std::begin(fighter.trinketItemLevels), std::end(fighter.trinketItemLevels), profile.trinketItemLevels); + profile.isPvp = fighter.isPvp; return profile; } @@ -808,6 +831,7 @@ int Match::member_join(Sim& sim, int member, const Profile& profile) std::copy(std::begin(profile.trinketSlotLevels), std::end(profile.trinketSlotLevels), fighter.trinketSlotLevels); std::copy(std::begin(profile.weaponItemLevels), std::end(profile.weaponItemLevels), fighter.weaponItemLevels); std::copy(std::begin(profile.trinketItemLevels), std::end(profile.trinketItemLevels), fighter.trinketItemLevels); + fighter.isPvp = profile.isPvp; owners[index] = member; std::memcpy(names[index], profile.name, sizeof(names[index])); names[index][PLAYER_NAME_MAX] = '\0'; diff --git a/src/match.h b/src/match.h index eb28564..64e2a3c 100644 --- a/src/match.h +++ b/src/match.h @@ -156,6 +156,7 @@ struct Profile int trinketSlotLevels[SLOT_MAX] = {}; int weaponItemLevels[WEAPON_COUNT] = {}; int trinketItemLevels[TRINKET_COUNT] = {}; + bool isPvp = false; }; // The default rules for a mode: a best-of-three deathmatch for two, or a three minute race for four. @@ -191,6 +192,8 @@ public: float clock = 0; float countdown = READY_TIME; float finishTimes[MAX_FIGHTERS] = {}; + // How long until each fighter can splat another tomato on the results. + float tomatoCooldowns[MAX_FIGHTERS] = {}; float timer = 0; int round = 0; int winner = -1; @@ -248,7 +251,8 @@ 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 tomatoes_update(Sim& sim); + bool 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/menu.cpp b/src/menu.cpp index 8b3bd13..7cb4fcd 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -66,10 +66,19 @@ static Rect tab_area_get(Game& game, Rect header) void Menu::update(Game& game) { Input& input = game.input; - // Readying up while choosing closes the menu, back to the countdown with the loadout's tooltips. + // A countdown for choosing items opens the menu on that kind's tab, however it began (here, or on the server for + // a game this screen joins), with one player on this screen, and the fight starting closes it. Readying up while + // choosing closes it too, back to the countdown with the loadout's tooltips. Match& match = game.sim.match; + sandbox_levels_update(game); + bool isPicking = match.state == MATCH_COUNTDOWN && (match.isWeaponPick || match.isTrinketPick); + if (isPicking && !wasPicking && game.local_count() == 1) + choice_open(game); + if (!isPicking && wasPicking) + isOpen = false; + wasPicking = isPicking; bool isReady = match.state == MATCH_COUNTDOWN && match.isReady[game.local]; - if (isReady && !wasReady && (match.isWeaponPick || match.isTrinketPick)) + if (isReady && !wasReady && isPicking) isOpen = false; wasReady = isReady; // Escape works as the menu control too. On a match's results (with the menu closed), either goes back to the @@ -186,7 +195,7 @@ void Menu::update(Game& game) int step = (int)input.is_key_control_pressed(CONTROL_LEVEL_UP) - (int)input.is_key_control_pressed(CONTROL_LEVEL_DOWN); - if (step != 0 && game.sim.match.state == MATCH_PRACTICE) + if (step != 0) level_step(game, step, mouse); } @@ -194,6 +203,16 @@ void Menu::update(Game& game) // equipped (and any copies equipped later). void Menu::level_step(Game& game, int step, Vec2 mouse) { + if (game.sim.match.state != MATCH_PRACTICE) + { + if (is_choosable(game, tab == TAB_TRINKETS)) + return; + int& level = sandbox_levels_get(tab == TAB_TRINKETS)[selections[tab]]; + int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); + isLevelsPending |= next != level; + level = next; + return; + } Fighter& fighter = game.local_get(); for (int kind = 0; kind < ITEM_TABS; kind++) { @@ -214,6 +233,42 @@ void Menu::level_step(Game& game, int step, Vec2 mouse) edit_encode({.type = EDIT_ITEM_LEVEL, .isTrinket = isTrinket, .index = item, .value = next})); } +// Keeps the sandbox's item levels while in it. Back from a match that levelled them, they go back in as edits instead, +// one at a time, until the fighter has them all. +void Menu::sandbox_levels_update(Game& game) +{ + if (game.sim.match.state != MATCH_PRACTICE) + return; + Fighter& fighter = game.local_get(); + for (int kind = 0; kind < ITEM_TABS; kind++) + { + bool isTrinket = kind == TAB_TRINKETS; + int* levels = sandbox_levels_get(isTrinket); + int* own = isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels; + int count = isTrinket ? TRINKET_COUNT : WEAPON_COUNT; + for (int item = 0; item < count && isLevelsPending; item++) + { + if (levels[item] == own[item]) + continue; + int device = game.devices[game.local]; + if (game.input.devices[device].editCount == 0) + game.input.edit_send( + device, + edit_encode( + {.type = EDIT_ITEM_LEVEL, .isTrinket = isTrinket, .index = item, .value = levels[item]})); + return; + } + if (!isLevelsPending) + std::copy(own, own + count, levels); + } + isLevelsPending = false; +} + +int* Menu::sandbox_levels_get(bool isTrinket) +{ + return isTrinket ? sandboxTrinketLevels : sandboxWeaponLevels; +} + // Asks for one copy's level to move by step, through this screen's controls. void Menu::slot_level_step(Game& game, bool isTrinket, int slot, int step) { @@ -288,7 +343,7 @@ void Menu::pad_update(Game& game) isOpen = false; game.audio.play(isOpen ? SOUND_SELECT : SOUND_SWAP); } - if (tab >= ITEM_TABS || game.sim.match.state != MATCH_PRACTICE) + if (tab >= ITEM_TABS) return; int level = (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_NORTH) - (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_WEST); @@ -304,8 +359,9 @@ void Menu::hints_draw(Game& game, MenuLayout& layout) return; const char* hint = "A: Select B: Close LB/RB: Tabs"; if (tab < ITEM_TABS) - hint = game.sim.match.state == MATCH_PRACTICE ? "A: Equip/Unequip X/Y: Level RS: Scroll Info LB/RB: Tabs" - : "A: Equip/Unequip RS: Scroll Info B: Close LB/RB: Tabs"; + hint = game.sim.match.state == MATCH_PRACTICE + ? "A: Equip/Unequip X/Y: Level RS: Scroll Info LB/RB: Tabs" + : "A: Equip/Unequip X/Y: Sandbox Level RS: Scroll Info B: Close LB/RB: Tabs"; else if (tab == TAB_PLAY && (page == PAGE_RULES || page == PAGE_QUEUE)) hint = "A: Select Left/Right: Change B: Back Y: Join"; else if (tab == TAB_SETTINGS) @@ -461,9 +517,15 @@ void Menu::items_draw(Game& game, MenuLayout& layout) renderer.rect_draw(layout.divider, ui.tint); // The description is of the selection, or of a hotbar slot clicked (or focused) on this tab while it still holds // something. + // In a match (but not while choosing from this tab), the selection shows at its level in the sandbox, which is + // what levels here. + bool isMatch = game.sim.match.state != MATCH_PRACTICE; bool isTrinket = tab == TAB_TRINKETS; + bool isSandboxLevel = isMatch && !is_choosable(game, isTrinket); int item = selections[tab]; - int level = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[item]; + int level = (isSandboxLevel ? sandbox_levels_get(isTrinket) + : isTrinket ? fighter.trinketItemLevels + : fighter.weaponItemLevels)[item]; int* slotLevel = nullptr; if (pinnedTab != tab) pinnedSlot = -1; @@ -480,22 +542,24 @@ void Menu::items_draw(Game& game, MenuLayout& layout) if (shown != described) description.scroll = 0; described = shown; - Tooltip tooltip = ui.tooltip_build(game, isTrinket, item, level); + // Only what can be levelled here previews its next level. + bool isLevelable = !isMatch || (isSandboxLevel && !slotLevel); + Tooltip tooltip = ui.tooltip_build(game, isTrinket, item, level, isLevelable); if (slotLevel) tooltip.slot = pinnedSlot; Rect area = layout.description; - bool isLevelable = game.sim.match.state == MATCH_PRACTICE; if (isLevelable) area.h -= WIDGET_HEIGHT + LEVEL_ROW_GAP; ui.tooltip_page_draw(game, description, area, tooltip); - // In the sandbox, a row under the description levels what it shows: the copy in a clicked hotbar slot, or else the - // selection with every copy of it equipped. A button greys out at the end of the levels. + // A row under the description levels what it shows: in the sandbox, the copy in a clicked hotbar slot, or else the + // selection with every copy of it equipped, and in a match, the selection's sandbox level. A button greys out at + // the end of the levels. if (!isLevelable) return; // The level between a minus and a plus, centred. char text[TOOLTIP_LENGTH]; - SDL_snprintf(text, sizeof(text), "Level %d", level + 1); + SDL_snprintf(text, sizeof(text), isSandboxLevel ? "Sandbox Level %d" : "Level %d", level + 1); Vec2 size = game.text.size_get(text); float width = WIDGET_HEIGHT * 2 + LEVEL_ROW_GAP * 2 + size.x; float x = std::round(area.x + (area.w - width) / 2); @@ -522,6 +586,12 @@ void Menu::items_draw(Game& game, MenuLayout& layout) void Menu::play_draw(Game& game, MenuLayout& layout) { Ui& ui = game.ui; + // In a match, it's only the game's rules. + if (game.sim.match.state != MATCH_PRACTICE) + { + ui.game_summary_draw(game, game.sim.match.rules, {layout.body.x, layout.body.y}); + return; + } crumbs_draw(game, layout); Rect area = layout.body; area.y += CRUMB_HEIGHT + DIVIDER; @@ -1031,8 +1101,9 @@ bool Menu::is_bars_open() return isOpen && tab < ITEM_TABS; } -// Fills tabs with the ones shown, in order, returning how many: every tab in the sandbox, both item tabs during a -// countdown that lets fighters choose either kind, and only Settings anywhere else. +// Fills tabs with the ones shown, in order, returning how many: every tab in the sandbox, and in a match the item tabs +// (to choose from when its countdown lets fighters, and otherwise to look at and level the sandbox's), Play (the +// game's rules) and Settings. int Menu::tabs_get(Game& game, int* tabs) { Match& match = game.sim.match; @@ -1043,13 +1114,8 @@ int Menu::tabs_get(Game& game, int* tabs) tabs[count++] = i; return count; } - if (match.isWeaponPick || match.isTrinketPick) - { - tabs[count++] = TAB_WEAPONS; - tabs[count++] = TAB_TRINKETS; - } - if (count == 0) - tabs[count++] = TAB_SETTINGS; + for (int tab : {TAB_WEAPONS, TAB_TRINKETS, TAB_PLAY, TAB_SETTINGS}) + tabs[count++] = tab; return count; } diff --git a/src/menu.h b/src/menu.h index 542158f..9e44374 100644 --- a/src/menu.h +++ b/src/menu.h @@ -88,7 +88,12 @@ private: int targets[ITEM_TABS] = {}; int pinnedTab = -1; bool isPinnedTrinket = false; + bool wasPicking = false; bool wasReady = false; + // The sandbox's item levels, kept while in it, so a match can level them for when this screen is back. + int sandboxWeaponLevels[WEAPON_COUNT] = {}; + int sandboxTrinketLevels[TRINKET_COUNT] = {}; + bool isLevelsPending = false; int scrolls[ITEM_TABS] = {}; int selections[ITEM_TABS] = {}; int page = PAGE_MAIN; @@ -112,6 +117,8 @@ private: void loadout_set(Game& game, bool isTrinket, int slot, int item); void item_toggle(Game& game); void level_step(Game& game, int step, Vec2 mouse); + void sandbox_levels_update(Game& game); + int* sandbox_levels_get(bool isTrinket); void slot_level_step(Game& game, bool isTrinket, int slot, int step); void pad_update(Game& game); void hints_draw(Game& game, MenuLayout& layout); diff --git a/src/protocol.h b/src/protocol.h index 1673637..a012f5e 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -27,7 +27,7 @@ #include #include -constexpr int PROTOCOL_VERSION = 3; +constexpr int PROTOCOL_VERSION = 4; constexpr int LOBBY_MEMBERS_MAX = 16; constexpr int LOBBY_CODE_LENGTH = 4; // Letters only, without I and O, so codes are easy to read out and type. @@ -49,6 +49,7 @@ constexpr int SERVER_URL_LENGTH = 128; // PACKET_CHAT Uint16 length, text // PACKET_TYPING Uint8 isTyping // PACKET_RENAME Uint16 length, text +// PACKET_PING Uint16 milliseconds this player's round trip to the server // // Server to game: // PACKET_STATE room, tick, state the game this player is in now, as a tick starts @@ -58,7 +59,7 @@ constexpr int SERVER_URL_LENGTH = 128; // once the events before them are in, and how early // (in ticks) this player's last controls came // PACKET_CHECKSUM room, tick, Uint32 the room's state as that tick starts -// PACKET_CHAT from, Uint16 length, text and the same for typing and renames +// PACKET_CHAT from, Uint16 length, text and the same for typing, renames and pings enum PacketType : unsigned char { PACKET_PROFILE = 1, @@ -70,7 +71,8 @@ enum PacketType : unsigned char PACKET_STATE, PACKET_EVENT, PACKET_FRAMES, - PACKET_CHECKSUM + PACKET_CHECKSUM, + PACKET_PING }; // Packets are plain values one after another, as they sit in memory (every platform agrees on these layouts). diff --git a/src/scene.cpp b/src/scene.cpp index 5eb73dd..8d3c34b 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -69,6 +69,7 @@ constexpr float WHIP_INNER = 2; constexpr float WHIP_JITTER = 0.5f; constexpr float WHIP_OUTER = 5; constexpr float WISP_FRAME_STAGGER = 0.3f; +constexpr Burst TOMATO_BURST = {.gravity = 400, .life = 0.5f, .size = 2, .speed = 140, .spread = 1.2f, .count = 12}; // A random offset for an empowered attack or fighter to shake by, wider the more its colour fills in, like a damage // number. @@ -390,16 +391,22 @@ void Scene::match_start() game.menu.isOpen = false; } -void Scene::choice_open() +// A tomato thrown on the results: a splat in the thrower's colour where it landed, spraying juice. +void Scene::tomato_splat(Vec2 position, int owner) { - if (game.local_count() == 1) - game.menu.choice_open(game); + if (isMuted) + return; + Color color = game.color_get(owner, true); + game.effects.splat_spawn(position, color); + game.effects.burst(TOMATO_BURST, position, -PI / 2, color); + game.audio.play(SOUND_SPLAT); } -void Scene::choice_close() +// Readying with empty slots dealt them: this screen's own fighters hear the items go in. +void Scene::deal_play(int fighter) { - if (game.local_count() > 0) - game.menu.isOpen = false; + if (!isMuted && game.is_local(fighter)) + game.audio.play(SOUND_EQUIP); } void Scene::sandbox_return() diff --git a/src/scene.h b/src/scene.h index 10ed19c..390f120 100644 --- a/src/scene.h +++ b/src/scene.h @@ -35,8 +35,8 @@ public: void number_spawn(Damage& damage) override; void level_step(bool isUp) override; void match_start() override; - void choice_open() override; - void choice_close() override; + void deal_play(int fighter) override; + void tomato_splat(Vec2 position, int owner) override; void sandbox_return() override; private: diff --git a/src/session.cpp b/src/session.cpp index 01407aa..d31db2d 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -112,6 +112,7 @@ void Session::update(Game& game, Controls local) if (!isJoined) return; profile_watch(game); + ping_report(game); confirm(game); rewind(game); frames.erase(frames.begin(), frames.lower_bound(confirmed)); @@ -211,6 +212,14 @@ void Session::packet_handle(Game& game, const std::string& bytes) game.chat.typing[from] = isTyping != 0; return; } + if (type == PACKET_PING) + { + Uint16 ping = 0; + for (NetMember& member : game.net.members) + if (member.id == from && from != game.net.selfId && reader.get(ping)) + member.ping = ping; + return; + } Uint16 length = 0; if (!reader.get(length) || reader.at + length > bytes.size()) return; @@ -569,7 +578,17 @@ void Session::queue_send(Game& game, const Rules& rules, bool isOpen) float Session::ping_get(Game& game, int member) { for (NetMember& netMember : game.net.members) - if (netMember.id == member && member == game.net.selfId) + if (netMember.id == member) return netMember.ping; return PING_NONE; } + +// This screen's own round trip goes to the lobby every so often, for everyone else's connection bars. +void Session::ping_report(Game& game) +{ + float ping = ping_get(game, game.net.selfId); + if (ping < 0 || SDL_GetTicks() < nextPingReport) + return; + nextPingReport = SDL_GetTicks() + PING_REPORT_INTERVAL; + game.net.send(PacketWriter().put(PACKET_PING).put((Uint16)std::min(ping, (float)UINT16_MAX)).bytes, true); +} diff --git a/src/session.h b/src/session.h index 699ad59..1e9eb53 100644 --- a/src/session.h +++ b/src/session.h @@ -8,6 +8,8 @@ struct Game; constexpr int SESSION_RING = 64; +// How often (in milliseconds) this screen tells the lobby its round trip. +constexpr Uint64 PING_REPORT_INTERVAL = 2000; // A member's connection has no ping to show. constexpr float PING_NONE = -2; @@ -91,6 +93,7 @@ private: // next, and whether it's told its pending one is waiting. Uint64 nextRename = 0; bool isRenameWaiting = false; + Uint64 nextPingReport = 0; void start(Game& game); void packets_take(Game& game); @@ -103,5 +106,6 @@ private: void inputs_send(Game& game); void local_find(Game& game); void profile_watch(Game& game); + void ping_report(Game& game); void text_send(Game& game, PacketType type, const std::string& text); }; diff --git a/src/settings.cpp b/src/settings.cpp index 8822240..79a5470 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -41,6 +41,7 @@ constexpr const char* KEY_HUD_SPEED = "hud_speed"; constexpr const char* KEY_VSYNC = "vsync"; constexpr const char* KEY_WEAPON_SLOTS = "weapon_slots"; constexpr const char* SETTINGS_FILE = "settings.txt"; +constexpr const char* KEY_SANDBOX_PVP = "sandbox_pvp"; constexpr const char* KEY_SANDBOX_SLOT = "sandbox_slot"; constexpr const char* KEY_SANDBOX_TRINKET_LEVELS = "sandbox_trinket_levels"; constexpr const char* KEY_SANDBOX_TRINKETS = "sandbox_trinkets"; @@ -201,6 +202,8 @@ static void sandbox_read(SandboxSave& sandbox, const char* key, char* value) sandbox.feet.y = SDL_atof(value); else if (SDL_strcmp(key, KEY_SANDBOX_SLOT) == 0) sandbox.slot = SDL_atoi(value); + else if (SDL_strcmp(key, KEY_SANDBOX_PVP) == 0) + sandbox.isPvp = SDL_atoi(value) != 0; else if (SDL_strcmp(key, KEY_SANDBOX_WEAPONS) == 0) list_read(value, false, sandbox.weaponLoadout, sandbox.weaponSlotLevels); else if (SDL_strcmp(key, KEY_SANDBOX_TRINKETS) == 0) @@ -216,8 +219,9 @@ static void sandbox_read(SandboxSave& sandbox, const char* key, char* value) static int sandbox_write(Fighter& fighter, char* text, int size) { - int length = SDL_snprintf(text, size, "%s=%g\n%s=%g\n%s=%d\n", KEY_SANDBOX_X, fighter.center_get().x, KEY_SANDBOX_Y, - fighter.body.box.y + fighter.body.box.h, KEY_SANDBOX_SLOT, fighter.slot); + int length = SDL_snprintf(text, size, "%s=%g\n%s=%g\n%s=%d\n%s=%d\n", KEY_SANDBOX_X, fighter.center_get().x, + KEY_SANDBOX_Y, fighter.body.box.y + fighter.body.box.h, KEY_SANDBOX_SLOT, fighter.slot, + KEY_SANDBOX_PVP, (int)fighter.isPvp); for (int kind = 0; kind < 2; kind++) { bool isTrinket = kind == 1; @@ -621,9 +625,9 @@ void Settings::customize_draw(Game& game, Rect area) save(game); } -// Puts the sandbox back as it was saved, once the world and fighter exist: loadouts, levels, the slot held, and where -// the fighter stood (unless that spot is now blocked). With nothing saved yet, the fighter starts with a sword in its -// first slot. +// Puts the sandbox back as it was saved, once the world and fighter exist: loadouts, levels, the slot held, PvP, and +// where the fighter stood (unless that spot is now blocked). With nothing saved yet, the fighter starts with a sword in +// its first slot. void Settings::restore(Game& game) { Fighter& fighter = game.local_get(); @@ -636,6 +640,7 @@ void Settings::restore(Game& game) std::copy(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), fighter.trinketLoadout); std::copy(std::begin(sandbox.weaponLevels), std::end(sandbox.weaponLevels), fighter.weaponItemLevels); std::copy(std::begin(sandbox.trinketLevels), std::end(sandbox.trinketLevels), fighter.trinketItemLevels); + fighter.isPvp = sandbox.isPvp; // Saves from before copies had their own levels give each copy its item's level. for (int i = 0; i < SLOT_MAX; i++) { diff --git a/src/settings.h b/src/settings.h index 76bbba7..a509ebc 100644 --- a/src/settings.h +++ b/src/settings.h @@ -22,6 +22,7 @@ struct SandboxSave int weaponLevels[WEAPON_COUNT] = {}; int weaponLoadout[SLOT_MAX]; int weaponSlotLevels[SLOT_MAX]; + bool isPvp = false; bool isSaved = false; }; diff --git a/src/sim.h b/src/sim.h index 37abb29..f77544b 100644 --- a/src/sim.h +++ b/src/sim.h @@ -39,9 +39,9 @@ public: {} virtual void match_start() {} - virtual void choice_open() + virtual void deal_play(int fighter) {} - virtual void choice_close() + virtual void tomato_splat(Vec2 position, int owner) {} virtual void sandbox_return() {} diff --git a/src/trinket.h b/src/trinket.h index 24bde3f..5b31f01 100644 --- a/src/trinket.h +++ b/src/trinket.h @@ -86,7 +86,8 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { {0.1f, 0.25f, 1 / 3.0f}, "Stud Multiplier"}, {"Dual Wield", - "Your last-held weapon fires from\nyour off hand too, for a cut of\nits damage. Each copy adds another\nweapon, the next most recent.", + "Your last-held weapon fires from\nyour off hand too, for a cut of\nits damage. Each copy adds another\nweapon, " + "the next most recent.", {"10% Off-Hand Damage", "25% Off-Hand Damage", "33% Off-Hand Damage"}, {0.1f, 0.25f, 1 / 3.0f}, "Guns akimbo!"}, @@ -110,9 +111,9 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { {"Shock Absorber", "Your own weapons move you around\nless (recoil, kickback, lunges,\nrebounds, pulls and blasts), and\nyou take " "less knockback.", - {"12.5% Less Weapon Movement\n8% Less Knockback Taken", "25% Less Weapon Movement\n17% Less Knockback Taken", - "37.5% Less Weapon Movement\n25% Less Knockback Taken"}, - {0.875f, 0.75f, 0.625f}, + {"17% Less Weapon Movement\n11% Less Knockback Taken", "33% Less Weapon Movement\n22% Less Knockback Taken", + "50% Less Weapon Movement\n33% Less Knockback Taken"}, + {5 / 6.0f, 2 / 3.0f, 0.5f}, "The mechanical kind, not the\nelectrical kind."}, {"Shrinker", "Makes you smaller.", diff --git a/src/ui.cpp b/src/ui.cpp index bf9bffa..d916436 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -1190,9 +1190,9 @@ static void effects_preview_get(Game& game, const char* now, const char* next, c SDL_strlcpy(text, result.c_str(), size); } -// Name, numbers at a level and description of a weapon or trinket. Trinkets say how many copies are equipped, and -// when they stack. -Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level) +// Name, numbers at a level and description of a weapon or trinket, previewing the next level's where asked (the +// catalog). Trinkets say how many copies are equipped, and when they stack. +Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, bool isPreview) { Tooltip tooltip; tooltip.isTrinket = isTrinket; @@ -1214,8 +1214,8 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level) else SDL_strlcpy(equipped, copies > 0 ? "Equipped" : "Not Equipped", sizeof(equipped)); tooltip.name = def.name; - // In the sandbox, the effects show what the next level changes after an arrow, as weapons' stats do. - bool isPreview = game.sim.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; + // Previewed (in the catalog), the effects show what the next level changes after an arrow, as stats do. + isPreview &= level < LEVEL_COUNT - 1; char effects[TOOLTIP_LENGTH]; if (isPreview) effects_preview_get(game, def.effects[level], def.effects[level + 1], effects, sizeof(effects)); @@ -1234,8 +1234,8 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level) const WeaponDef& def = WEAPON_DEFS[item]; tooltip.name = def.name; - // In the sandbox, values that change at the next level show it after an arrow. - bool isPreview = game.sim.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; + // Previewed (in the catalog), values that change at the next level show it after an arrow. + isPreview &= level < LEVEL_COUNT - 1; SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), isPreview ? "(Level %d → Level %d)" : "(Level %d)", level + 1, level + 2); stats_table_build(fighter, item, level, isPreview, tooltip); diff --git a/src/ui.h b/src/ui.h index dc89c30..d303441 100644 --- a/src/ui.h +++ b/src/ui.h @@ -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); + Tooltip tooltip_build(Game& game, bool isTrinket, int item, int level, bool isPreview = false); 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); diff --git a/tools/sfx_build.py b/tools/sfx_build.py index 04f0feb..1533ae8 100644 --- a/tools/sfx_build.py +++ b/tools/sfx_build.py @@ -109,6 +109,7 @@ PICKS = { "slam": {"f": "US03-39-2", "s": 0.16, "n": 0.6, "sp": 0.7}, "slash": {"f": "4003_47_2", "s": 0.792, "n": 0.2}, "slash_heavy": {"f": "4003_47_2", "s": 0.792, "n": 0.35, "sp": 0.75}, + "splat": {"f": "4001_32_1", "n": 0.5}, "step": {"f": "4003_46_2", "s": 0.796, "n": 0.08, "g": 0.6, "r": 16000}, "swap": {"keep": "demo1"}, "swing": {"f": "4003_47_1", "s": 0.395, "n": 0.3}, diff --git a/tools/sfx_loudness.json b/tools/sfx_loudness.json index c83d07a..e9a1775 100644 --- a/tools/sfx_loudness.json +++ b/tools/sfx_loudness.json @@ -72,6 +72,7 @@ "slam": 0.5555170721984241, "slash": 0.3046866383126178, "slash_heavy": 0.5043262612182655, + "splat": 0.35, "step": 0.0363354998815522, "swap": 0.10673649790216319, "swing": 0.20927146265655572,