From 37560547d4cc5d8205ac19d5d133f6535430c284 Mon Sep 17 00:00:00 2001 From: shweet Date: Tue, 10 Nov 2026 23:01:32 -0500 Subject: [PATCH] Lobby browser, names and passwords, The Good Stuff, smarter queues, and a balance pass - Lobbies: a name (default "Lobby "), Unlisted or Public, and an optional password. The Multiplayer page scrolls: a Browser of public lobbies (name, count, padlock, code, little player previews, Join with a password field when locked), Join by code, Create, then Settings. Errors show under whatever caused them. Links take ?pass= (and --pass), and Copy Link includes it. Protocol 11. - Queue areas center on their host along the floor's flat stretch, squeezing their slots to fit, and move to a nearby surface when the host's is too short. - An airblast steals an enemy's flail ball onto its own chain for a while. Flail balls reel themselves back after 15 seconds. Doubler echoes of the Grimoire teleport move you too. - The Good Stuff: a new trinket, the inverse of Diazepam (more spread). Spread is now a multiplier, and it widens or narrows the Vacuum's cone. - Balance: Sword basic slash 25% smaller and charged slash half the size, Whip basic lash half the damage and reach, Recoil Coil recoil halved, Speedometer no longer speeds you up. - Fixes: fashions survive coming back from a game, Quit shows only in the sandbox menu, and the chat log keeps above the menu's tip. --- resources/gfx/trinkets.anm2 | 230 ++++++++++++++++--------------- resources/gfx/trinkets.png | Bin 6890 -> 7187 bytes server/server.cpp | 59 +++++++- src/chat.cpp | 5 +- src/main.cpp | 29 ++-- src/match.cpp | 74 +++++----- src/match.h | 2 + src/menu.cpp | 266 +++++++++++++++++++++++++++++------- src/menu.h | 12 +- src/net.cpp | 96 ++++++++++--- src/net.h | 47 ++++++- src/protocol.h | 7 +- src/trinket.h | 21 +-- src/ui.cpp | 7 +- src/ui.h | 4 +- src/weapon.cpp | 49 ++++--- src/weapon.h | 35 +++-- 17 files changed, 666 insertions(+), 277 deletions(-) diff --git a/resources/gfx/trinkets.anm2 b/resources/gfx/trinkets.anm2 index a67f3cd..d4c5bc4 100644 --- a/resources/gfx/trinkets.anm2 +++ b/resources/gfx/trinkets.anm2 @@ -12,22 +12,23 @@ {"name": "Hourglass", "crop": [96, 0], "size": [24, 24], "origin": "center"}, {"name": "Enlarger", "crop": [120, 0], "size": [24, 24], "origin": "center"}, {"name": "Diazepam", "crop": [0, 24], "size": [24, 24], "origin": "center"}, - {"name": "Splitter", "crop": [24, 24], "size": [24, 24], "origin": "center"}, - {"name": "Doubler", "crop": [48, 24], "size": [24, 24], "origin": "center"}, - {"name": "Dual Wield", "crop": [72, 24], "size": [24, 24], "origin": "center"}, - {"name": "Ricochet", "crop": [96, 24], "size": [24, 24], "origin": "center"}, - {"name": "Tesla Coil", "crop": [120, 24], "size": [24, 24], "origin": "center"}, - {"name": "Recoil Coil", "crop": [0, 48], "size": [24, 24], "origin": "center"}, - {"name": "Shock Absorber", "crop": [24, 48], "size": [24, 24], "origin": "center"}, - {"name": "Shrinker", "crop": [48, 48], "size": [24, 24], "origin": "center"}, - {"name": "Embiggener", "crop": [72, 48], "size": [24, 24], "origin": "center"}, - {"name": "Pebble Helmet", "crop": [96, 48], "size": [24, 24], "origin": "center"}, - {"name": "Boots", "crop": [120, 48], "size": [24, 24], "origin": "center"}, - {"name": "Speedometer", "crop": [0, 72], "size": [24, 24], "origin": "center"}, - {"name": "Wings", "crop": [24, 72], "size": [24, 24], "origin": "center"}, - {"name": "Feather", "crop": [48, 72], "size": [24, 24], "origin": "center"}, - {"name": "Iron Weights", "crop": [72, 72], "size": [24, 24], "origin": "center"}, - {"name": "Climb Claw", "crop": [96, 72], "size": [24, 24], "origin": "center"}, + {"name": "The Good Stuff", "crop": [24, 24], "size": [24, 24], "origin": "center"}, + {"name": "Splitter", "crop": [48, 24], "size": [24, 24], "origin": "center"}, + {"name": "Doubler", "crop": [72, 24], "size": [24, 24], "origin": "center"}, + {"name": "Dual Wield", "crop": [96, 24], "size": [24, 24], "origin": "center"}, + {"name": "Ricochet", "crop": [120, 24], "size": [24, 24], "origin": "center"}, + {"name": "Tesla Coil", "crop": [0, 48], "size": [24, 24], "origin": "center"}, + {"name": "Recoil Coil", "crop": [24, 48], "size": [24, 24], "origin": "center"}, + {"name": "Shock Absorber", "crop": [48, 48], "size": [24, 24], "origin": "center"}, + {"name": "Shrinker", "crop": [72, 48], "size": [24, 24], "origin": "center"}, + {"name": "Embiggener", "crop": [96, 48], "size": [24, 24], "origin": "center"}, + {"name": "Pebble Helmet", "crop": [120, 48], "size": [24, 24], "origin": "center"}, + {"name": "Boots", "crop": [0, 72], "size": [24, 24], "origin": "center"}, + {"name": "Speedometer", "crop": [24, 72], "size": [24, 24], "origin": "center"}, + {"name": "Wings", "crop": [48, 72], "size": [24, 24], "origin": "center"}, + {"name": "Feather", "crop": [72, 72], "size": [24, 24], "origin": "center"}, + {"name": "Iron Weights", "crop": [96, 72], "size": [24, 24], "origin": "center"}, + {"name": "Climb Claw", "crop": [120, 72], "size": [24, 24], "origin": "center"}, {"name": "Gloves Small", "crop": [0, 96], "size": [16, 16], "origin": "center"}, {"name": "Energy Drink Small", "crop": [16, 96], "size": [16, 16], "origin": "center"}, {"name": "Boxing Glove Small", "crop": [32, 96], "size": [16, 16], "origin": "center"}, @@ -35,22 +36,23 @@ {"name": "Hourglass Small", "crop": [64, 96], "size": [16, 16], "origin": "center"}, {"name": "Enlarger Small", "crop": [80, 96], "size": [16, 16], "origin": "center"}, {"name": "Diazepam Small", "crop": [0, 112], "size": [16, 16], "origin": "center"}, - {"name": "Splitter Small", "crop": [16, 112], "size": [16, 16], "origin": "center"}, - {"name": "Doubler Small", "crop": [32, 112], "size": [16, 16], "origin": "center"}, - {"name": "Dual Wield Small", "crop": [48, 112], "size": [16, 16], "origin": "center"}, - {"name": "Ricochet Small", "crop": [64, 112], "size": [16, 16], "origin": "center"}, - {"name": "Tesla Coil Small", "crop": [80, 112], "size": [16, 16], "origin": "center"}, - {"name": "Recoil Coil Small", "crop": [0, 128], "size": [16, 16], "origin": "center"}, - {"name": "Shock Absorber Small", "crop": [16, 128], "size": [16, 16], "origin": "center"}, - {"name": "Shrinker Small", "crop": [32, 128], "size": [16, 16], "origin": "center"}, - {"name": "Embiggener Small", "crop": [48, 128], "size": [16, 16], "origin": "center"}, - {"name": "Pebble Helmet Small", "crop": [64, 128], "size": [16, 16], "origin": "center"}, - {"name": "Boots Small", "crop": [80, 128], "size": [16, 16], "origin": "center"}, - {"name": "Speedometer Small", "crop": [0, 144], "size": [16, 16], "origin": "center"}, - {"name": "Wings Small", "crop": [16, 144], "size": [16, 16], "origin": "center"}, - {"name": "Feather Small", "crop": [32, 144], "size": [16, 16], "origin": "center"}, - {"name": "Iron Weights Small", "crop": [48, 144], "size": [16, 16], "origin": "center"}, - {"name": "Climb Claw Small", "crop": [64, 144], "size": [16, 16], "origin": "center"}, + {"name": "The Good Stuff Small", "crop": [16, 112], "size": [16, 16], "origin": "center"}, + {"name": "Splitter Small", "crop": [32, 112], "size": [16, 16], "origin": "center"}, + {"name": "Doubler Small", "crop": [48, 112], "size": [16, 16], "origin": "center"}, + {"name": "Dual Wield Small", "crop": [64, 112], "size": [16, 16], "origin": "center"}, + {"name": "Ricochet Small", "crop": [80, 112], "size": [16, 16], "origin": "center"}, + {"name": "Tesla Coil Small", "crop": [0, 128], "size": [16, 16], "origin": "center"}, + {"name": "Recoil Coil Small", "crop": [16, 128], "size": [16, 16], "origin": "center"}, + {"name": "Shock Absorber Small", "crop": [32, 128], "size": [16, 16], "origin": "center"}, + {"name": "Shrinker Small", "crop": [48, 128], "size": [16, 16], "origin": "center"}, + {"name": "Embiggener Small", "crop": [64, 128], "size": [16, 16], "origin": "center"}, + {"name": "Pebble Helmet Small", "crop": [80, 128], "size": [16, 16], "origin": "center"}, + {"name": "Boots Small", "crop": [0, 144], "size": [16, 16], "origin": "center"}, + {"name": "Speedometer Small", "crop": [16, 144], "size": [16, 16], "origin": "center"}, + {"name": "Wings Small", "crop": [32, 144], "size": [16, 16], "origin": "center"}, + {"name": "Feather Small", "crop": [48, 144], "size": [16, 16], "origin": "center"}, + {"name": "Iron Weights Small", "crop": [64, 144], "size": [16, 16], "origin": "center"}, + {"name": "Climb Claw Small", "crop": [80, 144], "size": [16, 16], "origin": "center"}, {"name": "Gloves Tiny", "crop": [0, 160], "size": [8, 8], "origin": "center"}, {"name": "Energy Drink Tiny", "crop": [8, 160], "size": [8, 8], "origin": "center"}, {"name": "Boxing Glove Tiny", "crop": [16, 160], "size": [8, 8], "origin": "center"}, @@ -58,28 +60,27 @@ {"name": "Hourglass Tiny", "crop": [32, 160], "size": [8, 8], "origin": "center"}, {"name": "Enlarger Tiny", "crop": [40, 160], "size": [8, 8], "origin": "center"}, {"name": "Diazepam Tiny", "crop": [0, 168], "size": [8, 8], "origin": "center"}, - {"name": "Splitter Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"}, - {"name": "Doubler Tiny", "crop": [16, 168], "size": [8, 8], "origin": "center"}, - {"name": "Dual Wield Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"}, - {"name": "Ricochet Tiny", "crop": [32, 168], "size": [8, 8], "origin": "center"}, - {"name": "Tesla Coil Tiny", "crop": [40, 168], "size": [8, 8], "origin": "center"}, - {"name": "Recoil Coil Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"}, - {"name": "Shock Absorber Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"}, - {"name": "Shrinker Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"}, - {"name": "Embiggener Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"}, - {"name": "Pebble Helmet Tiny", "crop": [32, 176], "size": [8, 8], "origin": "center"}, - {"name": "Boots Tiny", "crop": [40, 176], "size": [8, 8], "origin": "center"}, - {"name": "Speedometer Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"}, - {"name": "Wings Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"}, - {"name": "Feather Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"}, - {"name": "Iron Weights Tiny", "crop": [24, 184], "size": [8, 8], "origin": "center"}, - {"name": "Climb Claw Tiny", "crop": [32, 184], "size": [8, 8], "origin": "center"} + {"name": "The Good Stuff Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"}, + {"name": "Splitter Tiny", "crop": [16, 168], "size": [8, 8], "origin": "center"}, + {"name": "Doubler Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"}, + {"name": "Dual Wield Tiny", "crop": [32, 168], "size": [8, 8], "origin": "center"}, + {"name": "Ricochet Tiny", "crop": [40, 168], "size": [8, 8], "origin": "center"}, + {"name": "Tesla Coil Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"}, + {"name": "Recoil Coil Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"}, + {"name": "Shock Absorber Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"}, + {"name": "Shrinker Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"}, + {"name": "Embiggener Tiny", "crop": [32, 176], "size": [8, 8], "origin": "center"}, + {"name": "Pebble Helmet Tiny", "crop": [40, 176], "size": [8, 8], "origin": "center"}, + {"name": "Boots Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"}, + {"name": "Speedometer Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"}, + {"name": "Wings Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"}, + {"name": "Feather Tiny", "crop": [24, 184], "size": [8, 8], "origin": "center"}, + {"name": "Iron Weights Tiny", "crop": [32, 184], "size": [8, 8], "origin": "center"}, + {"name": "Climb Claw Tiny", "crop": [40, 184], "size": [8, 8], "origin": "center"} ] } ], - "layers": [ - {"name": "sprite"} - ], + "layers": [{"name": "sprite"}], "defaultAnimation": "Gloves", "animations": { "Gloves": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, @@ -89,67 +90,70 @@ "Hourglass": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, "Enlarger": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, "Diazepam": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, - "Splitter": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, - "Doubler": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, - "Dual Wield": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, - "Ricochet": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, - "Tesla Coil": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, - "Recoil Coil": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, - "Shock Absorber": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, - "Shrinker": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, - "Embiggener": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, - "Pebble Helmet": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, - "Boots": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, - "Speedometer": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, - "Wings": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]}, - "Feather": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, - "Iron Weights": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, - "Climb Claw": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, - "Gloves Small": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]}, - "Energy Drink Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]}, - "Boxing Glove Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]}, - "Accelerator Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]}, - "Hourglass Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]}, - "Enlarger Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]}, - "Diazepam Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, - "Splitter Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, - "Doubler Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, - "Dual Wield Small": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, - "Ricochet Small": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, - "Tesla Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]}, - "Recoil Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]}, - "Shock Absorber Small": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]}, - "Shrinker Small": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]}, - "Embiggener Small": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]}, - "Pebble Helmet Small": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]}, - "Boots Small": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]}, - "Speedometer Small": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]}, - "Wings Small": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]}, - "Feather Small": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]}, - "Iron Weights Small": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]}, - "Climb Claw Small": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]}, - "Gloves Tiny": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]}, - "Energy Drink Tiny": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]}, - "Boxing Glove Tiny": {"layers": [{"layer": 0, "frames": [{"region": 48}]}]}, - "Accelerator Tiny": {"layers": [{"layer": 0, "frames": [{"region": 49}]}]}, - "Hourglass Tiny": {"layers": [{"layer": 0, "frames": [{"region": 50}]}]}, - "Enlarger Tiny": {"layers": [{"layer": 0, "frames": [{"region": 51}]}]}, - "Diazepam Tiny": {"layers": [{"layer": 0, "frames": [{"region": 52}]}]}, - "Splitter Tiny": {"layers": [{"layer": 0, "frames": [{"region": 53}]}]}, - "Doubler Tiny": {"layers": [{"layer": 0, "frames": [{"region": 54}]}]}, - "Dual Wield Tiny": {"layers": [{"layer": 0, "frames": [{"region": 55}]}]}, - "Ricochet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 56}]}]}, - "Tesla Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 57}]}]}, - "Recoil Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 58}]}]}, - "Shock Absorber Tiny": {"layers": [{"layer": 0, "frames": [{"region": 59}]}]}, - "Shrinker Tiny": {"layers": [{"layer": 0, "frames": [{"region": 60}]}]}, - "Embiggener Tiny": {"layers": [{"layer": 0, "frames": [{"region": 61}]}]}, - "Pebble Helmet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 62}]}]}, - "Boots Tiny": {"layers": [{"layer": 0, "frames": [{"region": 63}]}]}, - "Speedometer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 64}]}]}, - "Wings Tiny": {"layers": [{"layer": 0, "frames": [{"region": 65}]}]}, - "Feather Tiny": {"layers": [{"layer": 0, "frames": [{"region": 66}]}]}, - "Iron Weights Tiny": {"layers": [{"layer": 0, "frames": [{"region": 67}]}]}, - "Climb Claw Tiny": {"layers": [{"layer": 0, "frames": [{"region": 68}]}]} + "The Good Stuff": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, + "Splitter": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, + "Doubler": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, + "Dual Wield": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, + "Ricochet": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, + "Tesla Coil": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, + "Recoil Coil": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, + "Shock Absorber": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, + "Shrinker": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, + "Embiggener": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, + "Pebble Helmet": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, + "Boots": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, + "Speedometer": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]}, + "Wings": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, + "Feather": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, + "Iron Weights": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, + "Climb Claw": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]}, + "Gloves Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]}, + "Energy Drink Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]}, + "Boxing Glove Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]}, + "Accelerator Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]}, + "Hourglass Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]}, + "Enlarger Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, + "Diazepam Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, + "The Good Stuff Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, + "Splitter Small": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, + "Doubler Small": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, + "Dual Wield Small": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]}, + "Ricochet Small": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]}, + "Tesla Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]}, + "Recoil Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]}, + "Shock Absorber Small": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]}, + "Shrinker Small": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]}, + "Embiggener Small": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]}, + "Pebble Helmet Small": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]}, + "Boots Small": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]}, + "Speedometer Small": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]}, + "Wings Small": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]}, + "Feather Small": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]}, + "Iron Weights Small": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]}, + "Climb Claw Small": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]}, + "Gloves Tiny": {"layers": [{"layer": 0, "frames": [{"region": 48}]}]}, + "Energy Drink Tiny": {"layers": [{"layer": 0, "frames": [{"region": 49}]}]}, + "Boxing Glove Tiny": {"layers": [{"layer": 0, "frames": [{"region": 50}]}]}, + "Accelerator Tiny": {"layers": [{"layer": 0, "frames": [{"region": 51}]}]}, + "Hourglass Tiny": {"layers": [{"layer": 0, "frames": [{"region": 52}]}]}, + "Enlarger Tiny": {"layers": [{"layer": 0, "frames": [{"region": 53}]}]}, + "Diazepam Tiny": {"layers": [{"layer": 0, "frames": [{"region": 54}]}]}, + "The Good Stuff Tiny": {"layers": [{"layer": 0, "frames": [{"region": 55}]}]}, + "Splitter Tiny": {"layers": [{"layer": 0, "frames": [{"region": 56}]}]}, + "Doubler Tiny": {"layers": [{"layer": 0, "frames": [{"region": 57}]}]}, + "Dual Wield Tiny": {"layers": [{"layer": 0, "frames": [{"region": 58}]}]}, + "Ricochet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 59}]}]}, + "Tesla Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 60}]}]}, + "Recoil Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 61}]}]}, + "Shock Absorber Tiny": {"layers": [{"layer": 0, "frames": [{"region": 62}]}]}, + "Shrinker Tiny": {"layers": [{"layer": 0, "frames": [{"region": 63}]}]}, + "Embiggener Tiny": {"layers": [{"layer": 0, "frames": [{"region": 64}]}]}, + "Pebble Helmet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 65}]}]}, + "Boots Tiny": {"layers": [{"layer": 0, "frames": [{"region": 66}]}]}, + "Speedometer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 67}]}]}, + "Wings Tiny": {"layers": [{"layer": 0, "frames": [{"region": 68}]}]}, + "Feather Tiny": {"layers": [{"layer": 0, "frames": [{"region": 69}]}]}, + "Iron Weights Tiny": {"layers": [{"layer": 0, "frames": [{"region": 70}]}]}, + "Climb Claw Tiny": {"layers": [{"layer": 0, "frames": [{"region": 71}]}]} } } diff --git a/resources/gfx/trinkets.png b/resources/gfx/trinkets.png index 1c519ae4f8a98d1b5077b44b484cf707d411a597..0ab6a4ae87aab29521335dfa1c6593b7a4e6d11e 100644 GIT binary patch literal 7187 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 literal 6890 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 members; std::vector rooms; + bool isPublic = false; }; // What the network's threads hand the game loop: a member's connection opening or closing, one of their data channels @@ -329,7 +333,8 @@ static void lobby_enter(int id, const std::string& code) list += (list.empty() ? "" : ",") + std::string("{\"id\":") + std::to_string(other) + ",\"name\":" + json_quote(members[other].name) + "}"; send(id, "{\"type\":\"welcome\",\"id\":" + std::to_string(id) + ",\"code\":" + json_quote(code) + - ",\"host\":" + std::to_string(lobby.members.front()) + ",\"members\":[" + list + "]}"); + ",\"name\":" + json_quote(lobby.name) + ",\"host\":" + std::to_string(lobby.members.front()) + + ",\"members\":[" + list + "]}"); for (int other : lobby.members) if (other != id) send(other, "{\"type\":\"member_join\",\"id\":" + std::to_string(id) + @@ -360,7 +365,35 @@ static void lobby_exit(int id, LeaveReason leave = LEAVE_QUIT, const std::string ",\"host\":" + std::to_string(lobby.members.front()) + reason + "}"); } -// The lobby messages, in JSON: making, joining and leaving lobbies, kicking, and keeping the connection busy. +// Tells a member about every public lobby: its code and name, how many are in it, whether it needs a password, and each +// player's color and fashion (as their fighter in its sandbox has them, or as they brought it while in a game), for +// little previews of them. +static void lobbies_send(int id) +{ + std::string list; + for (auto& [code, lobby] : lobbies) + { + if (!lobby.isPublic || lobby.rooms.empty()) + continue; + Sim& sandbox = rooms[lobby.rooms.front()].sim; + std::string players; + for (int member : lobby.members) + { + int fighter = sandbox.match.fighter_find(sandbox, member); + Profile& profile = members[member].profile; + int color = fighter >= 0 ? sandbox.match.colors[fighter] : profile.color; + int fashion = fighter >= 0 ? sandbox.match.fashions[fighter] : profile.fashion; + players += (players.empty() ? "" : ",") + std::string("{\"color\":") + std::to_string(color) + + ",\"fashion\":" + std::to_string(fashion) + "}"; + } + list += (list.empty() ? "" : ",") + std::string("{\"code\":") + json_quote(code) + + ",\"name\":" + json_quote(lobby.name) + ",\"locked\":" + (lobby.password.empty() ? "false" : "true") + + ",\"players\":[" + players + "]}"; + } + send(id, "{\"type\":\"lobbies\",\"lobbies\":[" + list + "]}"); +} + +// The lobby messages, in JSON: listing, making, joining and leaving lobbies, kicking, and keeping the connection busy. static void text_handle(int id, const std::string& text) { simdjson::dom::element message; @@ -371,12 +404,14 @@ static void text_handle(int id, const std::string& text) // Answered, so proxies see the connection busy both ways. if (type == "ping") return send(id, "{\"type\":\"pong\"}"); - if (type == "create" || type == "join") + if (type == "list" || type == "create" || type == "join") { int64_t version = 0; std::string_view name; if (message["version"].get(version) || version != PROTOCOL_VERSION) return error_send(id, "Different game version"); + if (type == "list") + return lobbies_send(id); if (!members[id].code.empty()) return error_send(id, "Already in a lobby"); members[id].name = name_clean(message["name"].get(name) ? "" : name); @@ -385,7 +420,15 @@ static void text_handle(int id, const std::string& text) std::string code = code_make(); if (code.empty()) return error_send(id, "No free lobby codes"); - std::printf("Lobby %s opened (%zu open)\n", code.c_str(), lobbies.size() + 1); + // A lobby's name defaults to its code, and it starts unlisted unless asked otherwise. + std::string_view field; + bool isPublic = false; + Lobby& lobby = lobbies[code]; + lobby.name = text_clean(message["lobby"].get(field) ? "" : field, LOBBY_NAME_MAX); + lobby.name = lobby.name.empty() ? "Lobby " + code : lobby.name; + lobby.password = text_clean(message["password"].get(field) ? "" : field, LOBBY_PASSWORD_MAX); + lobby.isPublic = !message["public"].get(isPublic) && isPublic; + std::printf("Lobby %s opened (%zu open)\n", code.c_str(), lobbies.size()); return lobby_enter(id, code); } std::string_view raw; @@ -399,6 +442,11 @@ static void text_handle(int id, const std::string& text) return error_send(id, "No lobby with that code"); if ((int)lobby->second.members.size() >= LOBBY_MEMBERS_MAX) return error_send(id, "That lobby is full"); + std::string_view password; + if (message["password"].get(password)) + password = ""; + if (!lobby->second.password.empty() && password != lobby->second.password) + return error_send(id, password.empty() ? LOBBY_PASSWORD_NEEDED : "Wrong password"); return lobby_enter(id, code); } if (type == "leave") @@ -634,6 +682,7 @@ static void game_open(int sandboxSerial, int queuer) members[players[k]].profile = profile_get(sandbox.sim.fighters[seats[k]]); std::memcpy(members[players[k]].profile.name, lobby.names[seats[k]], sizeof(lobby.names[seats[k]])); members[players[k]].profile.color = lobby.colors[seats[k]]; + members[players[k]].profile.fashion = lobby.fashions[seats[k]]; } int serial = room_open(sandbox.code); Room& game = rooms[serial]; diff --git a/src/chat.cpp b/src/chat.cpp index fbf049b..3444807 100644 --- a/src/chat.cpp +++ b/src/chat.cpp @@ -364,7 +364,7 @@ void Chat::command(Game& game, const std::string& text) system("Unknown command. Type /help for a list of commands."); } -// Where the log goes: the bottom right, just above the other players' windows when they're showing. +// Where the log goes: the bottom right, just above the other players' windows (or a tip) when they're showing. Rect Chat::log_area_get(Game& game) { float bottom = CANVAS_HEIGHT - HOTBAR_MARGIN; @@ -375,6 +375,9 @@ Rect Chat::log_area_get(Game& game) for (int k = 0; k < rivals.count; k++) bottom = std::min(bottom, rivals.cards[k].y - CHAT_GAP); } + // A tip along the bottom (under the menu) keeps the log above it. + if (game.menu.isOpen && game.settings.isTipsShown) + bottom = std::min(bottom, game.ui.tip_low_get() - CHAT_GAP); return {CANVAS_WIDTH - HOTBAR_MARGIN - CHAT_WIDTH, 0, CHAT_WIDTH, bottom}; } diff --git a/src/main.cpp b/src/main.cpp index a46d577..5175e61 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -227,20 +227,23 @@ extern "C" EMSCRIPTEN_KEEPALIVE void frame_hidden() // Runs the game in a window (on the web, filling the page and driven by the browser's frames), or with "--balance N" // plays N bot matches headless and prints the results ("--balance N race" plays races). -// The lobby to join on starting, if any: from the page's ?lobby= in the browser, or --join on the command line. -static std::string lobby_requested(int argc, char** argv) +// A launch option, if given: from the page's ?= in the browser, or on the command line. A lobby +// to join on starting is ?lobby= or --join, with its password (if it needs one) ?pass= or --pass. +static std::string launch_option_get(int argc, char** argv, const char* query, const char* flag) { #ifdef SDL_PLATFORM_EMSCRIPTEN - char* code = (char*)EM_ASM_PTR({ - var code = new URLSearchParams(location.search).get('lobby'); - return code ? stringToNewUTF8(code) : 0; - }); - std::string lobby = code ? code : ""; - free(code); - return lobby; + char* found = (char*)EM_ASM_PTR( + { + var value = new URLSearchParams(location.search).get(UTF8ToString($0)); + return value ? stringToNewUTF8(value) : 0; + }, + query); + std::string option = found ? found : ""; + free(found); + return option; #else for (int i = 1; i + 1 < argc; i++) - if (SDL_strcmp(argv[i], "--join") == 0) + if (SDL_strcmp(argv[i], flag) == 0) return argv[i + 1]; return ""; #endif @@ -283,11 +286,11 @@ int main(int argc, char** argv) game.renderer.atlas_upload(); game.settings.apply(game); game.settings.restore(game); - std::string lobby = lobby_requested(argc, argv); + std::string lobby = launch_option_get(argc, argv, "lobby", "--join"); if (!lobby.empty()) { - game.net.lobby_join(game, lobby.c_str()); - game.menu.online_open(); + game.net.lobby_join(game, lobby, launch_option_get(argc, argv, "pass", "--pass")); + game.menu.online_open(lobby); } game.camera = camera_target_get(game); game.cameraPrevious = game.camera; diff --git a/src/match.cpp b/src/match.cpp index 2b73fa3..6213e77 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -620,15 +620,17 @@ void Match::sandbox_return(Sim& sim) fighter.state_reset(sim, 1); } -// Online, opens a fighter's queued game: an area on the ground big enough for everyone it's for, as near the fighter -// as there's room (on the floor below it, or the nearest stretch of floor to either side that's flat, clear overhead -// and not another queue's), with the fighter standing still at its left end, readied up. +// Online, opens a fighter's queued game: an area on flat ground with a slot for everyone it's for, centered on the +// fighter where there's room, with the fighter standing still in its middle, readied up. It goes on the floor at or +// below the fighter, or the nearest one to either side with room, along a stretch that's flat, clear overhead and not +// another queue's, squeezing its slots (down to QUEUE_SLOT_MIN) to fit a short one. void Match::queue_open(Sim& sim, int fighter, const Rules& wanted) { World& world = sim.world; Queue& queue = queues[fighter]; queue = {.rules = rules_clean(wanted), .isActive = true}; float width = queue.rules.players * QUEUE_SLOT_WIDTH + QUEUE_MARGIN * 2; + float narrowest = queue.rules.players * QUEUE_SLOT_MIN + QUEUE_MARGIN * 2; Vec2 size = world.size_get(); Fighter& queuer = sim.fighters[fighter]; Vec2 feet = {queuer.body.box.x + queuer.body.box.w / 2, queuer.body.box.y + queuer.body.box.h}; @@ -640,41 +642,49 @@ void Match::queue_open(Sim& sim, int fighter, const Rules& wanted) return y; return -1.0f; }; - auto is_fitting = [&](float left, float floor) { - if (left < 0 || left + width > size.x || floor < QUEUE_HEIGHT) + // Whether a queue's area could stand over a point on a floor: flat ground, clear overhead, nobody else's queue. + auto is_standable = [&](float x, float floor) { + if (x < 0 || x > size.x || !world.is_blocking_at({x, floor + 1}) || world.is_blocking_at({x, floor - 1})) return false; - for (float x = left; x <= left + width; x += QUEUE_PROBE_STEP) - { - if (!world.is_blocking_at({x, floor + 1}) || world.is_blocking_at({x, floor - 1})) + for (float up = QUEUE_PROBE_STEP; up <= QUEUE_HEIGHT; up += QUEUE_PROBE_STEP) + if (world.is_solid_at({x, floor - up})) return false; - for (float up = QUEUE_PROBE_STEP; up <= QUEUE_HEIGHT; up += QUEUE_PROBE_STEP) - if (world.is_solid_at({x, floor - up})) - return false; - } - Rect area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; for (int i = 0; i < sim.fighterCount; i++) - if (i != fighter && queues[i].isActive && is_rect_overlapping(area, queues[i].area)) + { + Rect other = queues[i].area; + if (i != fighter && queues[i].isActive && x >= other.x && x <= other.x + other.w && floor > other.y && + floor - QUEUE_HEIGHT < other.y + other.h) return false; + } return true; }; - float start = std::round(feet.x - QUEUE_MARGIN - QUEUE_SLOT_WIDTH / 2); - queue.area = {start, feet.y - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; - for (float offset = 0; offset < size.x; offset += QUEUE_PROBE_STEP) - { - bool isFound = false; - for (float left : {start + offset, start - offset}) - { - float floor = floor_find(left + width / 2, std::round(feet.y - TILE_SIZE)); - if (floor < 0 || !is_fitting(left, floor)) - continue; - queue.area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; - isFound = true; - break; - } - if (isFound) - break; - } - queuer.position_set(sim, {queue.area.x + QUEUE_MARGIN + QUEUE_SLOT_WIDTH / 2, queue.area.y + queue.area.h}); + // Places the area on the stretch of a floor around x, as wide as it can be up to its full width and centered on + // middle where the stretch allows, if the stretch is long enough for it squeezed. + auto place = [&](float x, float floor, float middle) { + if (floor < QUEUE_HEIGHT || !is_standable(x, floor)) + return false; + float left = x; + float right = x; + while (left - x > -width && is_standable(left - QUEUE_PROBE_STEP, floor)) + left -= QUEUE_PROBE_STEP; + while (right - x < width && is_standable(right + QUEUE_PROBE_STEP, floor)) + right += QUEUE_PROBE_STEP; + float span = std::min(width, right - left); + if (span < narrowest) + return false; + float center = std::clamp(middle, left + span / 2, right - span / 2); + queue.area = {std::round(center - span / 2), floor - QUEUE_HEIGHT, std::round(span), QUEUE_HEIGHT}; + return true; + }; + + queue.area = {std::round(feet.x - width / 2), feet.y - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; + float below = std::round(feet.y - TILE_SIZE); + bool isPlaced = place(feet.x, floor_find(feet.x, below), feet.x); + for (float offset = QUEUE_PROBE_STEP; offset < size.x && !isPlaced; offset += QUEUE_PROBE_STEP) + for (float x : {feet.x + offset, feet.x - offset}) + if (!isPlaced) + isPlaced = place(x, floor_find(x, below), x); + queuer.position_set(sim, {queue.area.x + queue.area.w / 2, queue.area.y + queue.area.h}); std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == fighter; }); queuedIn[fighter] = fighter; isQueueReady[fighter] = false; diff --git a/src/match.h b/src/match.h index e4b8b78..93e8774 100644 --- a/src/match.h +++ b/src/match.h @@ -12,6 +12,8 @@ constexpr int TIMER_MAX = 10; // A queued game's area: room per player, a margin each end, its height, how finely the ground's checked for one, and // how far below its floor feet still count as in it. constexpr float QUEUE_SLOT_WIDTH = 24; +// The narrowest a queue's slots squeeze to, to fit a short stretch of floor (a fighter is BOX_WIDTH wide). +constexpr float QUEUE_SLOT_MIN = 14; constexpr float QUEUE_MARGIN = 6; constexpr float QUEUE_HEIGHT = 40; constexpr float QUEUE_PROBE_STEP = 4; diff --git a/src/menu.cpp b/src/menu.cpp index d83dccd..9ea47ed 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -13,6 +13,15 @@ constexpr float BUTTON_WIDTH = 120; constexpr float CODE_FIELD_WIDTH = 60; constexpr float CODE_SCALE = 2; constexpr float JOIN_BUTTON_WIDTH = 40; +constexpr float PASSWORD_FIELD_WIDTH = 100; +// The Unlisted and Public choices' spacing, and how often the lobby list is asked for while it shows. +constexpr float RADIO_SPACING = 96; +constexpr Uint64 LISTING_INTERVAL = 3000; +// A listed lobby's padlock (its shackle the top part), and how faint the rule under each lobby is. +constexpr float LOCK_WIDTH = 7; +constexpr float LOCK_HEIGHT = 8; +constexpr float LOCK_SHACKLE = 3; +constexpr float LISTING_RULE = 0.4f; constexpr float COPY_BUTTON_WIDTH = 80; constexpr Uint64 COPIED_SHOWN = 1500; constexpr const char LOBBY_PAGE_DEFAULT[] = "https://shweetz.net/smack-a-brother/"; @@ -446,9 +455,9 @@ void Menu::about_draw(Game& game, Rect area) } // Lays the header out: the tabs shown, then in a match its buttons (Ready while a round counts down, and Forfeit, or -// one to stop spectating), and on desktop Quit last. Each cell is as wide as its name (a button's longest label) and -// padding, with the room left shared out evenly, so the names sit evenly spaced whatever is showing. Tab names are big -// when they all fit. +// one to stop spectating), and in the sandbox on desktop, Quit. Each cell is as wide as its name (a button's longest +// label) and padding, with the room left shared out evenly, so the names sit evenly spaced whatever is showing. Tab +// names are big when they all fit. HeaderLayout Menu::header_layout_get(Game& game, Rect area) { Text& text = game.text; @@ -464,7 +473,8 @@ HeaderLayout Menu::header_layout_get(Game& game, Rect area) header.buttons[header.buttonCount++] = BUTTON_FORFEIT; } #ifndef __EMSCRIPTEN__ - header.buttons[header.buttonCount++] = BUTTON_QUIT; + if (match.state == MATCH_PRACTICE) + header.buttons[header.buttonCount++] = BUTTON_QUIT; #endif float buttons = 0; @@ -790,10 +800,188 @@ static Color member_color_get(Game& game, int member) return color >= 0 ? PALETTE[color] : game.ui.tint; } -// Online play. Outside a lobby: make one, or type a lobby's code to join it, with how the last attempt is going (or -// why it failed), and under it where lobbies are hosted. In one: its code (to read out or copy to friends) and leaving, -// queueing a game in its sandbox, and everyone in it with how their connection is doing, then the games being played, -// to spectate. +// Text made safe for a link's query (letters and digits as they are, everything else as %XX). +static std::string url_encode(const std::string& text) +{ + std::string encoded; + for (unsigned char c : text) + { + char hex[4]; + SDL_snprintf(hex, sizeof(hex), "%%%02X", c); + encoded += SDL_isalnum(c) ? std::string(1, (char)c) : std::string(hex); + } + return encoded; +} + +// A padlock with its top-left corner at a point: a shackle over a body. +static void lock_draw(Renderer& renderer, Vec2 corner, Color color) +{ + renderer.rect_draw({corner.x + 1, corner.y, LOCK_WIDTH - 2, 1}, color); + renderer.rect_draw({corner.x + 1, corner.y, 1, LOCK_SHACKLE}, color); + renderer.rect_draw({corner.x + LOCK_WIDTH - 2, corner.y, 1, LOCK_SHACKLE}, color); + renderer.rect_draw({corner.x, corner.y + LOCK_SHACKLE, LOCK_WIDTH, LOCK_HEIGHT - LOCK_SHACKLE}, color); +} + +// Online play outside a lobby, a scrolling page: the public lobbies (asked for every LISTING_INTERVAL while this +// shows), each with its name, how full it is, a padlock when it needs a password, its code, a little of each player and +// a Join button (asking for the password first when there is one), then joining one by its code, making one (its name, +// its password and who can find it), and where lobbies are hosted. How the last attempt is going (or why it failed) +// shows under whichever made it. +void Menu::lobbies_draw(Game& game, Rect area) +{ + Ui& ui = game.ui; + Net& net = game.net; + Text& text = game.text; + Renderer& renderer = game.renderer; + Uint64 now = SDL_GetTicks(); + if (now >= nextListing) + { + net.lobbies_request(game); + nextListing = now + LISTING_INTERVAL; + } + if (net.error == LOBBY_PASSWORD_NEEDED) + passwordCode = joinTarget; + bool isBusy = net.state == NET_CONNECTING || net.state == NET_JOINING; + float rowWidth = area.w - SCROLL_WIDTH * 2; + float x = area.x + LABEL_WIDTH; + float y = 0; + auto status_draw = [&](float top) { + const char* line = net.state == NET_CONNECTING ? "Connecting to the server..." + : net.state == NET_JOINING ? "Joining..." + : net.error.c_str(); + text.draw(renderer, line, {.color = net.error.empty() ? ui.shade : ui.tint, .position = {area.x, top}}); + }; + auto is_status_shown = [&](const std::string& target) { + return joinTarget == target && (isBusy || !net.error.empty()); + }; + // A Join button, with the password field before it once a locked lobby asks for one. + auto join_draw = [&](float left, float top, const std::string& code, bool isLocked) { + bool isAsking = !code.empty() && passwordCode == code; + if (isAsking) + { + ui.field_draw(game, {left, top, PASSWORD_FIELD_WIDTH, WIDGET_HEIGHT}, joinPassword, sizeof(joinPassword)); + left += PASSWORD_FIELD_WIDTH + PADDING; + } + ui.widgets_enable(!isBusy && code.size() == LOBBY_CODE_LENGTH); + if (ui.button_draw(game, {left, top, JOIN_BUTTON_WIDTH, WIDGET_HEIGHT}, "Join", code.c_str())) + { + if (isLocked && !isAsking) + { + passwordCode = code; + joinPassword[0] = '\0'; + } + else + { + joinTarget = code; + net.lobby_join(game, code, isAsking ? joinPassword : ""); + } + } + ui.widgets_enable(true); + }; + + ui.page_begin(game, onlinePage, area); + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + ui.separator_draw(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "Browser"); + bool isListFailed = !net.listError.empty(); + if ((isListFailed || net.listings.empty()) && ui.row_get(onlinePage, ROW_HEIGHT, y)) + text.draw(renderer, isListFailed ? net.listError.c_str() : "No public lobbies right now", + {.color = isListFailed ? ui.tint : ui.shade, .position = {area.x, y + TEXT_ROW_INSET}}); + for (LobbyListing& listing : net.listings) + { + char count[TOOLTIP_LENGTH]; + SDL_snprintf(count, sizeof(count), "(%d/%d)", (int)listing.players.size(), LOBBY_MEMBERS_MAX); + float right = area.x + rowWidth - (listing.isLocked ? LOCK_WIDTH + PADDING : 0); + float countX = right - text.size_get(count).x; + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + { + std::string name = listing.name; + while (!name.empty() && text.size_get(name.c_str()).x > countX - area.x - PADDING) + name.pop_back(); + text.draw(renderer, name.c_str(), {.color = ui.tint, .position = {area.x, y + TEXT_ROW_INSET}}); + text.draw(renderer, count, {.color = ui.tint, .position = {countX, y + TEXT_ROW_INSET}}); + if (listing.isLocked) + lock_draw(renderer, {right + PADDING, y + TEXT_ROW_INSET}, ui.tint); + } + if (ui.row_get(onlinePage, LINE_HEIGHT, y)) + text.draw(renderer, listing.code.c_str(), {.color = ui.shade, .position = {area.x, y}}); + if (ui.row_get(onlinePage, MINI_PLAYER_ROW, y)) + for (size_t k = 0; k < listing.players.size(); k++) + { + auto [color, worn] = listing.players[k]; + Vec2 feet = {area.x + k * (MINI_PLAYER_WIDTH + PADDING / 2) + MINI_PLAYER_WIDTH / 2, + y + MINI_PLAYER_ROW - 1}; + ui.player_draw(game, feet, color >= 0 ? PALETTE[color % COLOR_COUNT] : ui.shade, 1, + fashion_clean(worn)); + } + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + join_draw(area.x, y, listing.code, listing.isLocked); + if (is_status_shown(listing.code) && ui.row_get(onlinePage, ROW_HEIGHT, y)) + status_draw(y + TEXT_ROW_INSET); + if (ui.row_get(onlinePage, PADDING, y)) + renderer.rect_draw({area.x, std::round(y + PADDING / 2), rowWidth, 1}, color_fade(ui.shade, LISTING_RULE)); + } + + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + ui.separator_draw(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "Join"); + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + { + ui.label_draw(game, "Code", {area.x, y}); + ui.field_draw(game, {x, y, CODE_FIELD_WIDTH, WIDGET_HEIGHT}, joinCode, sizeof(joinCode)); + for (char& c : joinCode) + c = (char)SDL_toupper(c); + join_draw(x + CODE_FIELD_WIDTH + PADDING, y, joinCode, false); + } + bool isListed = std::any_of(net.listings.begin(), net.listings.end(), + [&](LobbyListing& listing) { return listing.code == joinTarget; }); + if (!joinTarget.empty() && !isListed && is_status_shown(joinTarget) && ui.row_get(onlinePage, ROW_HEIGHT, y)) + status_draw(y + TEXT_ROW_INSET); + + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + ui.separator_draw(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "Create"); + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + { + ui.hint_set(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "The lobby's name. Blank\nnames it after its code."); + ui.label_draw(game, "Name", {area.x, y}); + ui.field_draw(game, {x, y, rowWidth - LABEL_WIDTH, WIDGET_HEIGHT}, lobbyOptions.name, + sizeof(lobbyOptions.name)); + } + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + { + ui.hint_set(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "What joining it takes.\nBlank for none."); + ui.label_draw(game, "Password", {area.x, y}); + ui.field_draw(game, {x, y, rowWidth - LABEL_WIDTH, WIDGET_HEIGHT}, lobbyOptions.password, + sizeof(lobbyOptions.password)); + } + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + { + ui.hint_set(game, {x, y, RADIO_SPACING, WIDGET_HEIGHT}, "Only its code or link\nfinds it."); + ui.hint_set(game, {x + RADIO_SPACING, y, RADIO_SPACING, WIDGET_HEIGHT}, "Anyone can find it in\nthe Browser."); + ui.radio_draw(game, {x, y}, "Unlisted", lobbyOptions.visibility, LOBBY_UNLISTED); + ui.radio_draw(game, {x + RADIO_SPACING, y}, "Public", lobbyOptions.visibility, LOBBY_PUBLIC); + } + if (ui.row_get(onlinePage, PLAY_HEIGHT + PADDING, y)) + { + ui.widgets_enable(!isBusy); + if (ui.button_draw(game, {area.x, y, rowWidth, PLAY_HEIGHT}, "Create")) + { + joinTarget.clear(); + net.lobby_create(game, lobbyOptions); + } + ui.widgets_enable(true); + } + if (is_status_shown("") && ui.row_get(onlinePage, ROW_HEIGHT, y)) + status_draw(y + TEXT_ROW_INSET); + + if (ui.row_get(onlinePage, ROW_HEIGHT, y)) + ui.separator_draw(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, "Settings"); + if (ui.row_get(onlinePage, ROW_HEIGHT * 2, y)) + game.settings.online_draw(game, {area.x, y, rowWidth, WIDGET_HEIGHT}, LABEL_WIDTH); + ui.page_end(game, onlinePage); +} + +// Online play. Outside a lobby, see lobbies_draw. In one: its name and code (to read out or copy to friends) and +// leaving, queueing a game in its sandbox, and everyone in it with how their connection is doing, then the games being +// played, to spectate. void Menu::online_draw(Game& game, Rect area) { Ui& ui = game.ui; @@ -806,43 +994,22 @@ void Menu::online_draw(Game& game, Rect area) }; if (net.state != NET_LOBBY) - { - bool isBusy = net.state == NET_CONNECTING || net.state == NET_JOINING; - ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Lobby"); - y += ROW_HEIGHT; - ui.widgets_enable(!isBusy); - if (ui.button_draw(game, {area.x, y, area.w, PLAY_HEIGHT}, "Create")) - net.lobby_create(game); - y += PLAY_HEIGHT + PADDING; - ui.label_draw(game, "Code", {area.x, y}); - Rect field = {area.x + LABEL_WIDTH, y, CODE_FIELD_WIDTH, WIDGET_HEIGHT}; - ui.field_draw(game, field, joinCode, sizeof(joinCode)); - for (char& c : joinCode) - c = (char)SDL_toupper(c); - ui.widgets_enable(!isBusy && SDL_strlen(joinCode) == LOBBY_CODE_LENGTH); - if (ui.button_draw(game, {field.x + field.w + PADDING, y, JOIN_BUTTON_WIDTH, WIDGET_HEIGHT}, "Join")) - net.lobby_join(game, joinCode); - ui.widgets_enable(true); - y += ROW_HEIGHT + PADDING; - if (net.state == NET_CONNECTING) - status_draw("Connecting to the server...", false); - else if (net.state == NET_JOINING) - status_draw("Joining...", false); - else if (!net.error.empty()) - status_draw(net.error.c_str(), true); + return lobbies_draw(game, area); - // The online settings, as in Settings. - ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Online"); - y += ROW_HEIGHT; - game.settings.online_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, LABEL_WIDTH); - return; - } - - // The lobby's code, big, with buttons to copy it (or a link to it) and to leave under it. + // The lobby's name, big (cut short to fit), its code faded under it, then buttons to copy the code (or a link to + // it, with its password when it has one) and to leave. char line[TOOLTIP_LENGTH]; - SDL_snprintf(line, sizeof(line), "Lobby %s", net.code.c_str()); - text.draw(game.renderer, line, {.color = ui.tint, .position = {area.x, y}, .scale = {CODE_SCALE, CODE_SCALE}}); - y += text.line_height_get(FONT_SMALL, CODE_SCALE) + PADDING; + std::string name = net.lobbyName; + while (!name.empty() && text.size_get((name + "...").c_str(), FONT_SMALL, CODE_SCALE).x > area.w && + text.size_get(name.c_str(), FONT_SMALL, CODE_SCALE).x > area.w) + name.pop_back(); + bool isCut = name != net.lobbyName; + text.draw(game.renderer, (isCut ? name + "..." : name).c_str(), + {.color = ui.tint, .position = {area.x, y}, .scale = {CODE_SCALE, CODE_SCALE}}); + y += text.line_height_get(FONT_SMALL, CODE_SCALE); + SDL_snprintf(line, sizeof(line), "Code %s", net.code.c_str()); + text.draw(game.renderer, line, {.color = ui.shade, .position = {area.x, y}}); + y += LINE_HEIGHT + PADDING; Uint64 now = SDL_GetTicks(); float third = std::floor((area.w - PADDING * 2) / 3); for (int i = 0; i < 2; i++) @@ -856,7 +1023,9 @@ void Menu::online_draw(Game& game, Rect area) : "Copy Link", i == 0 ? "copy code" : "copy link")) { - clipboard_set(i == 0 ? net.code : lobby_page_get() + "?lobby=" + net.code); + std::string link = lobby_page_get() + "?lobby=" + net.code; + link += net.password.empty() ? "" : "&pass=" + url_encode(net.password); + clipboard_set(i == 0 ? net.code : link); copiedButton = i; copiedUntil = now + COPIED_SHOWN; } @@ -1205,13 +1374,18 @@ void Menu::players_join(Game& game) } } -// Opens the menu on the Multiplayer page, as joining a lobby from a link does. -void Menu::online_open() +// Opens the menu on the Multiplayer page, as joining a lobby from a link does, with its code ready to join again (and +// its password asked for there, should it need one). +void Menu::online_open(const std::string& code) { isOpen = true; tab = TAB_PLAY; page = PAGE_ONLINE; isMultiplayer = true; + SDL_strlcpy(joinCode, code.c_str(), sizeof(joinCode)); + for (char& c : joinCode) + c = (char)SDL_toupper(c); + joinTarget = joinCode; } // Opens the menu on the item tab a round's countdown lets fighters choose from. diff --git a/src/menu.h b/src/menu.h index 7e61554..2df3971 100644 --- a/src/menu.h +++ b/src/menu.h @@ -1,5 +1,6 @@ #pragma once +#include "net.h" #include "common.h" #include "protocol.h" #include "ui.h" @@ -93,7 +94,7 @@ public: void update(Game& game); void draw(Game& game); void choice_open(Game& game); - void online_open(); + void online_open(const std::string& code); void targets_reset(Game& game); void target_fill(Game& game); bool is_bars_open(); @@ -106,6 +107,7 @@ public: private: Page description; Page lobbyPage; + Page onlinePage; int described = -1; // A hotbar slot clicked to read about (its item shown instead of the selection) while still on the tab it was // clicked on, or -1. @@ -130,6 +132,13 @@ private: Rules queueRules = rules_get(MODE_DEATHMATCH); // The lobby code typed on the Multiplayer page. char joinCode[LOBBY_CODE_LENGTH + 1] = {}; + // A new lobby's options, the password typed to join one (shown for passwordCode's lobby), the lobby the last join + // went to (whose row shows how it's going), and when to ask for the lobby list again. + LobbyOptions lobbyOptions; + char joinPassword[LOBBY_PASSWORD_MAX + 1] = {}; + std::string passwordCode; + std::string joinTarget; + Uint64 nextListing = 0; // Which lobby copy button (code or link) was last used, and until when it says so. int copiedButton = -1; Uint64 copiedUntil = 0; @@ -153,6 +162,7 @@ private: void header_draw(Game& game, MenuLayout& layout); void items_draw(Game& game, MenuLayout& layout); void play_draw(Game& game, MenuLayout& layout); + void lobbies_draw(Game& game, Rect area); void about_draw(Game& game, Rect area); void rules_draw(Game& game, Rect area); void online_draw(Game& game, Rect area); diff --git a/src/net.cpp b/src/net.cpp index 487df86..11e30b6 100644 --- a/src/net.cpp +++ b/src/net.cpp @@ -35,6 +35,8 @@ struct Net::Impl std::shared_ptr socket; PeerLink link; std::string pending; + // The state to wait in once the pending request goes (see request_send). + NetState waiting = NET_OFFLINE; simdjson::dom::parser parser; Uint64 nextPing = 0; // The network simulator (SAB_NET_LAG milliseconds on everything sent, SAB_NET_LOSS percent of input packets @@ -107,16 +109,32 @@ void Net::shutdown() impl->posted.clear(); } -void Net::lobby_create(Game& game) +void Net::lobby_create(Game& game, LobbyOptions& options) { - request_send(game, "{\"type\":\"create\",\"name\":" + json_quote(game.settings.name) + - ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}"); + password = options.password; + request_send(game, + "{\"type\":\"create\",\"name\":" + json_quote(game.settings.name) + + ",\"lobby\":" + json_quote(options.name) + ",\"password\":" + json_quote(options.password) + + ",\"public\":" + (options.visibility == LOBBY_PUBLIC ? "true" : "false") + + ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}", + NET_JOINING); } -void Net::lobby_join(Game& game, const char* lobby) +void Net::lobby_join(Game& game, const std::string& lobby, const std::string& lobbyPassword) { - request_send(game, "{\"type\":\"join\",\"code\":" + json_quote(lobby) + ",\"name\":" + - json_quote(game.settings.name) + ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}"); + password = lobbyPassword; + request_send(game, + "{\"type\":\"join\",\"code\":" + json_quote(lobby) + ",\"password\":" + json_quote(lobbyPassword) + + ",\"name\":" + json_quote(game.settings.name) + + ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}", + NET_JOINING); +} + +// Asks the server for its public lobbies (see listings), quietly: it doesn't count as joining anything. +void Net::lobbies_request(Game& game) +{ + if (state == NET_OFFLINE) + request_send(game, "{\"type\":\"list\",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}", NET_OFFLINE); } // Leaves the lobby, closing the WebRTC connection but keeping the socket to the server. @@ -131,23 +149,32 @@ void Net::lobby_leave() packets.clear(); kicks.clear(); code.clear(); + lobbyName.clear(); + password.clear(); selfId = hostId = -1; state = NET_OFFLINE; } // Sends a create or join, connecting to the server first if need be (it goes out once the socket opens). The // server is the one in Settings (Host), or SAB_SERVER_URL when set (for testing against a local one). -void Net::request_send(Game& game, const std::string& request) +void Net::request_send(Game& game, const std::string& request, NetState waiting) { - error.clear(); + bool isQuiet = waiting == NET_OFFLINE; + if (!isQuiet) + error.clear(); + isListing = isQuiet; if (impl->socket && impl->socket->isOpen()) { impl->socket->send(request); - state = NET_JOINING; + state = waiting; return; } + // A quiet request never takes the place of one waiting to join. + if (isQuiet && !impl->pending.empty()) + return; impl->pending = request; - state = NET_CONNECTING; + impl->waiting = waiting; + state = isQuiet ? state : NET_CONNECTING; if (impl->socket) return; impl->socket = std::make_shared(); @@ -164,7 +191,7 @@ void Net::request_send(Game& game, const std::string& request) return; impl->socket->send(impl->pending); impl->pending.clear(); - state = NET_JOINING; + state = impl->waiting; }); }); impl->socket->onMessage([this, shared](rtc::message_variant data) { @@ -182,17 +209,24 @@ void Net::request_send(Game& game, const std::string& request) lobby_leave(); impl->socket.reset(); impl->pending.clear(); - if (error.empty()) - error = "Lost the connection to the server"; + std::string& failure = failure_get(); + if (failure.empty()) + failure = "Lost the connection to the server"; }); }); impl->socket->onError([this, shared](std::string message) { - shared->post([this, message]() { error = "Couldn't reach the server (" + message + ")"; }); + shared->post([this, message]() { failure_get() = "Couldn't reach the server (" + message + ")"; }); }); const char* url = SDL_getenv("SAB_SERVER_URL"); impl->socket->open(url ? url : game.settings.serverUrl); } +// Where a failure goes: listError for a request only asking for the lobby list, error for everything else. +std::string& Net::failure_get() +{ + return isListing ? listError : error; +} + NetMember* Net::member_get(int id) { for (NetMember& member : members) @@ -213,8 +247,8 @@ void Net::message_handle(const std::string& text) if (type == "error") { - error = message["message"].get(field) ? "Server error" : std::string(field); - if (state != NET_LOBBY) + failure_get() = message["message"].get(field) ? "Server error" : std::string(field); + if (state != NET_LOBBY && !isListing) state = NET_OFFLINE; return; } @@ -224,6 +258,7 @@ void Net::message_handle(const std::string& text) selfId = message["id"].get(number) ? -1 : (int)number; hostId = message["host"].get(number) ? -1 : (int)number; code = message["code"].get(field) ? "" : std::string(field); + lobbyName = message["name"].get(field) ? "Lobby " + code : std::string(field); simdjson::dom::array list; if (!message["members"].get(list)) for (simdjson::dom::element entry : list) @@ -237,6 +272,35 @@ void Net::message_handle(const std::string& text) link_open(); return; } + if (type == "lobbies") + { + isListing = false; + listError.clear(); + listings.clear(); + simdjson::dom::array list; + if (message["lobbies"].get(list)) + return; + for (simdjson::dom::element entry : list) + { + LobbyListing listing; + listing.code = entry["code"].get(field) ? "" : std::string(field); + listing.name = text_clean(entry["name"].get(field) ? "" : field, LOBBY_NAME_MAX); + bool isLocked = false; + listing.isLocked = !entry["locked"].get(isLocked) && isLocked; + simdjson::dom::array players; + if (!entry["players"].get(players)) + for (simdjson::dom::element player : players) + { + int64_t color = -1; + int64_t fashion = 0; + std::ignore = player["color"].get(color); + std::ignore = player["fashion"].get(fashion); + listing.players.push_back({(int)color, (int)fashion}); + } + listings.push_back(listing); + } + return; + } if (type == "member_join") { NetMember member; diff --git a/src/net.h b/src/net.h index a25827e..8c38940 100644 --- a/src/net.h +++ b/src/net.h @@ -15,8 +15,6 @@ enum NetState NET_LOBBY }; -// Someone in the lobby (this screen's player too): their server id and name, and their connection's round trip to the -// server in milliseconds (-1 while it isn't known: this screen only measures its own). // A line for the chat log from the server, and the member it's about (-1 for none), in whose color it shows. struct NetNotice { @@ -24,6 +22,34 @@ struct NetNotice int member = -1; }; +// A public lobby as the server lists it: its code and name, whether it needs a password, and each player's color +// (-1 while unassigned) and fashion, for little previews. +struct LobbyListing +{ + std::string code; + std::string name; + std::vector> players; + bool isLocked = false; +}; + +// Whether the lobby list shows a lobby, or only its code or link finds it. +enum LobbyVisibility +{ + LOBBY_UNLISTED, + LOBBY_PUBLIC +}; + +// What a new lobby is made with: its name (empty for "Lobby "), its password (empty for none), and who can find +// it (see LobbyVisibility). +struct LobbyOptions +{ + char name[LOBBY_NAME_MAX + 1] = {}; + char password[LOBBY_PASSWORD_MAX + 1] = {}; + int visibility = LOBBY_UNLISTED; +}; + +// Someone in the lobby (this screen's player too): their server id and name, and their connection's round trip to the +// server in milliseconds (-1 while it isn't known: this screen only measures its own). struct NetMember { int id = -1; @@ -44,8 +70,9 @@ public: Net(); ~Net(); - void lobby_create(Game& game); - void lobby_join(Game& game, const char* code); + void lobby_create(Game& game, LobbyOptions& options); + void lobbies_request(Game& game); + void lobby_join(Game& game, const std::string& code, const std::string& password); void lobby_leave(); void update(); void shutdown(); @@ -58,6 +85,12 @@ public: NetState state = NET_OFFLINE; std::string code; std::string error; + // The lobby's name, and the password it was made or joined with (for its link). + std::string lobbyName; + std::string password; + // The public lobbies, as the server last listed them, and why the last listing failed (empty once one comes). + std::vector listings; + std::string listError; int selfId = -1; int hostId = -1; std::vector members; @@ -72,7 +105,7 @@ private: struct Impl; std::unique_ptr impl; - void request_send(Game& game, const std::string& request); + void request_send(Game& game, const std::string& request, NetState waiting); void send_now(const std::string& bytes, Delivery delivery); void ping_send(); void message_handle(const std::string& text); @@ -80,4 +113,8 @@ private: void signal_handle(const std::string& payload); NetMember* member_get(int id); void host_announce(int previous); + std::string& failure_get(); + + // Whether the request in flight is only asking for the lobby list, so its failures go to listError. + bool isListing = false; }; diff --git a/src/protocol.h b/src/protocol.h index 485b337..4a94f56 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -27,9 +27,14 @@ #include #include -constexpr int PROTOCOL_VERSION = 9; +constexpr int PROTOCOL_VERSION = 11; constexpr int LOBBY_MEMBERS_MAX = 16; constexpr int LOBBY_CODE_LENGTH = 4; +// The longest a lobby's name and password can be. +constexpr int LOBBY_NAME_MAX = 32; +constexpr int LOBBY_PASSWORD_MAX = 32; +// What the server says when a lobby's password is missing, which the game answers by asking for one. +constexpr const char* LOBBY_PASSWORD_NEEDED = "That lobby needs a password"; // Letters only, without I and O, so codes are easy to read out and type. constexpr const char LOBBY_CODE_LETTERS[] = "ABCDEFGHJKLMNPQRSTUVWXYZ"; constexpr int PLAYER_NAME_MAX = 24; diff --git a/src/trinket.h b/src/trinket.h index 4ff6058..59de1d9 100644 --- a/src/trinket.h +++ b/src/trinket.h @@ -14,6 +14,7 @@ enum TrinketId TRINKET_HOURGLASS, TRINKET_ENLARGER, TRINKET_DIAZEPAM, + TRINKET_GOOD, TRINKET_SPLITTER, TRINKET_DOUBLER, TRINKET_DUAL, @@ -65,7 +66,7 @@ struct TrinketStats float slamArea = 0; float slamDamage = 0; float speedDamage = 0; - float spreadCut = 0; + float spread = 0; }; // The stats, in the order tooltips list them. @@ -76,7 +77,7 @@ enum TrinketStat STAT_PROJECTILE_SPEED, STAT_LIFETIME, STAT_ATTACK_SIZE, - STAT_SPREAD_CUT, + STAT_SPREAD, STAT_COPIES, STAT_COPY_DAMAGE, STAT_REPEAT_DAMAGE, @@ -141,7 +142,7 @@ inline constexpr TrinketStatDef TRINKET_STATS[STAT_COUNT] = { {&TrinketStats::projectileSpeed, "Projectile Speed", FORMAT_TIMES, STACK_SCALE, 1}, {&TrinketStats::lifetime, "Projectile Lifetime", FORMAT_TIMES, STACK_SCALE, 1}, {&TrinketStats::attackSize, "Attack Size", FORMAT_TIMES, STACK_SCALE, 1}, - {&TrinketStats::spreadCut, "Spread", FORMAT_LEFT, STACK_SCALE, 0, 1}, + {&TrinketStats::spread, "Spread", FORMAT_TIMES, STACK_SCALE, 1}, {&TrinketStats::copies, "Copies", FORMAT_NUMBER, STACK_SCALE, 1}, {&TrinketStats::copyDamage, "Damage Each", FORMAT_TIMES, STACK_BEST}, {&TrinketStats::repeatDamage, "Repeat Damage", FORMAT_TIMES, STACK_BEST}, @@ -203,8 +204,12 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { "A noble spirit embiggens the\nsmallest projectile."}, {"Diazepam", "Decreases spread on attacks.", - {{.spreadCut = 1 / 6.0f}, {.spreadCut = 1 / 3.0f}, {.spreadCut = 0.5f}}, + {{.spread = 5 / 6.0f}, {.spread = 2 / 3.0f}, {.spread = 0.5f}}, "Diazepam?\nPsychotropic drug?\nStops hands from trembling?\nQuestions?"}, + {"The Good Stuff", + "Increases spread on attacks.", + {{.spread = 1.2f}, {.spread = 1.5f}, {.spread = 2}}, + "Steady hands are for\nsurgeons."}, {"Splitter", "Attacks split into smaller,\nless-damaging copies.", {{.copies = 2, .copyDamage = 1 / 3.0f}, @@ -229,7 +234,7 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { "Okay this is just Technology Zero."}, {"Recoil Coil", "All attacks have additional\nrecoil.", - {{.recoil = 6}, {.recoil = 9}, {.recoil = 12}}, + {{.recoil = 3}, {.recoil = 4.5f}, {.recoil = 6}}, "The forgotten sibling of Gravity\nand Speed..."}, {"Shock Absorber", "Receive less knockback, and\nattacks incur less movement.", @@ -256,10 +261,8 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { {{.runSpeed = 1.3f}, {.runSpeed = 1.65f}, {.runSpeed = 2}}, "THE BOOT!"}, {"Speedometer", - "Moving faster deals more damage.\nSpeed and acceleration\nincreased slightly.", - {{.runSpeed = 1.05f, .speedDamage = 0.05f}, - {.runSpeed = 1.1f, .speedDamage = 0.1f}, - {.runSpeed = 1.15f, .speedDamage = 0.15f}}, + "Moving faster deals more damage.", + {{.speedDamage = 0.05f}, {.speedDamage = 0.1f}, {.speedDamage = 0.15f}}, "When this baby hits 88 miles\nper hour..."}, {"Wings", "Dash in the air, following\nyour aim.", diff --git a/src/ui.cpp b/src/ui.cpp index 77f9f15..2b61459 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -223,6 +223,7 @@ constexpr Tip TIPS[] = { {"Use the Grimoire's teleporting Charged Attack to escape danger!", "Grimoire"}, {"Diazepam reduces Weapon spread, perfect for concentrated attacks!", "Diazepam"}, + {"The Good Stuff scatters your shots wider, and widens the Vacuum's reach to match!", "The Good Stuff"}, {"Splitter copies fan wider with each level!", "Splitter"}, {"The off-hand Weapon from Dual Wield is the one you last switched from!", "Dual Wield"}, {"Ricochet makes projectiles deal more damage the more they've bounced!", "Ricochet"}, @@ -3157,10 +3158,12 @@ void Ui::queues_draw(Game& game) renderer.rect_draw({area.x, area.y, 1, area.h}, edge); renderer.rect_draw({area.x + area.w - 1, area.y, 1, area.h}, edge); renderer.rect_draw({area.x, area.y, area.w, 1}, edge); + // A mark along the floor for each place, lit once filled, as wide as the area leaves them. + float slot = (area.w - QUEUE_MARGIN * 2) / queue.rules.players; for (int k = 0; k < queue.rules.players; k++) { - float x = std::round(area.x + QUEUE_MARGIN + k * QUEUE_SLOT_WIDTH + 2); - renderer.rect_draw({x, area.y + area.h - 2, QUEUE_SLOT_WIDTH - 4, 2}, + float x = std::round(area.x + QUEUE_MARGIN + k * slot + 2); + renderer.rect_draw({x, area.y + area.h - 2, std::round(slot) - 4, 2}, k < count ? color : color_shade(color, SECONDARY_SHADE)); } diff --git a/src/ui.h b/src/ui.h index dd521a6..bda9bf4 100644 --- a/src/ui.h +++ b/src/ui.h @@ -221,6 +221,8 @@ constexpr const char* HINT_KEYS[HINT_COUNT] = {"move", "jump", "down", "attack", class Ui { public: + void tip_draw(Game& game, float y); + float tip_low_get(); Color tint = WHITE; Color shade = GRAY; int adjust = 0; @@ -347,8 +349,6 @@ private: void hints_update(Game& game); int tip_pick(Game& game); void tips_update(Game& game); - void tip_draw(Game& game, float y); - float tip_low_get(); void hints_draw(Game& game); void text_center_draw(Game& game, const char* text, float y, float scale, float alpha = 1); void text_center_draw(Game& game, const char* text, float y, float scale, Color color); diff --git a/src/weapon.cpp b/src/weapon.cpp index 0134e3f..9c0a4c9 100644 --- a/src/weapon.cpp +++ b/src/weapon.cpp @@ -49,6 +49,8 @@ constexpr float PARTIAL_CHARGE_BONUS = 0.5f; // How many times as hard a shot hits once an airblast sends it back, anyone's, stacking with each one: twice what a // Ricochet bounce adds, as it takes aim and timing. constexpr float REFLECT_BONUS = 1.5f; +// How long a flail ball an airblast steals stays on its new owner's chain. +constexpr float STOLEN_CHAIN_TIME = 5; constexpr float SPEED_UNIT = 100; constexpr float CONTACT_INTERVAL = 0.4f; constexpr float WISP_GLIDE = 14; @@ -695,7 +697,7 @@ AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, Attack float gloves = fighter.stat_get(STAT_ATTACK_RATE); float drink = fighter.stat_get(STAT_CHARGE_RATE); float accelerator = attack.isProjectile ? fighter.stat_get(STAT_PROJECTILE_SPEED) : 1; - float steadiness = 1 - fighter.stat_get(STAT_SPREAD_CUT); + float spreading = fighter.stat_get(STAT_SPREAD); float absorb = fighter.stat_get(STAT_MOVEMENT); AttackSummary summary; @@ -714,8 +716,8 @@ AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, Attack summary.isAreaChanged = enlarger != 1 || splits > 1; summary.speed = attack.isProjectile ? stats.speed * accelerator : 0; summary.isSpeedChanged = accelerator != 1; - summary.spread = attack.spread * steadiness; - summary.isSpreadChanged = steadiness != 1; + summary.spread = attack.spread * spreading; + summary.isSpreadChanged = spreading != 1; summary.lunge = stats.lunge * absorb; summary.isLungeChanged = absorb != 1; summary.pieces = stats.volley * splits; @@ -804,7 +806,7 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) // A rope or chain is as long as its shot's flight carries it, so it lengthens as the flight does. if (def.isTethered) stats.range *= hourglass * accelerator; - float spread = 1 - fighter.stat_get(STAT_SPREAD_CUT); + float spread = fighter.stat_get(STAT_SPREAD); float jitter = def.spread * DEGREE * spread; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.rng.random_get(-jitter, jitter), 1); float push = def.isInheriting ? push_get(fighter, aim) : 0; @@ -826,11 +828,13 @@ bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) .fade = stats.fade > 0 ? stats.fade * hourglass : lifetime, .lifetime = lifetime, .scale = def.scale * stats.scale, + .spreading = spread, .bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0, .hitsLeft = def.hits, .owner = fighter.index, .weapon = held, .key = key, + .keyWeapon = held, .hand = shot.hand, .variant = variant, .isLeft = fighter.is_left(), @@ -1076,10 +1080,10 @@ void Weapons::attack_move(Sim& sim, Attack& attack) else if (def.path != PATH_OWN) flight_move(sim, attack); - // A mine, flail ball or grapple left out goes once its slot no longer holds the weapon that put it there (which - // could no longer set it off, reel it in or let it go), a mine fizzling with a puff. + // A mine, flail ball or grapple left out goes once its slot no longer holds the weapon that keeps it (which could + // no longer set it off, reel it in or let it go), a mine fizzling with a puff. bool isLeftOut = def.isArming || def.isTethered; - if (isLeftOut && !attack.isExplosion && fighter.weaponLoadout[attack.key] != attack.weapon) + if (isLeftOut && !attack.isExplosion && fighter.weaponLoadout[attack.key] != attack.keyWeapon) { attack.isDead = true; if (def.isArming) @@ -1349,8 +1353,11 @@ void Weapons::flight_move(Sim& sim, Attack& attack) } attack.tip = start; - bool isOver = !attack.isDead && attack.life_get() >= attack.lifetime; - if (isOver && def.isBursting) + // A tethered shot out of time reels itself back in (a flail ball, to whoever holds its chain now). + bool isOver = !attack.isDead && !attack.isReturning && attack.life_get() >= attack.lifetime; + if (isOver && def.isTethered) + attack.isReturning = true; + else if (isOver && def.isBursting) attack_explode(sim, attack); else if (isOver) { @@ -1751,7 +1758,8 @@ void Weapons::orbits_release(Fighter& fighter) // Grimoire teleport: sparkles round the fighter while it gathers, then moves them to the spot marked when it was cast // (backed off again if their box no longer fits), keeping all their momentum, with a blast there that hurts without -// knocking anyone about. Echoes only make the blast. +// knocking anyone about. A Doubler's echoes teleport again, each to where the cursor pointed as it was cast. An +// off-hand's (Dual Wield) only makes the blast. void Weapons::teleport_move(Sim& sim, Attack& attack) { Fighter& fighter = sim.fighters[attack.owner]; @@ -1759,14 +1767,14 @@ void Weapons::teleport_move(Sim& sim, Attack& attack) attack.hitsLeft = 0; if (attack.time < attack.duration) { - if (!attack.isCopy) + if (attack.hand == 0) sim.cues->burst_spawn(WARP_BURST, fighter.center_get(), 0, attack.owner); return; } attack.isDead = true; Vec2 spot = attack.position; - if (!attack.isCopy && attack.hand == 0 && fighter.isAlive) + if (attack.hand == 0 && fighter.isAlive) { sim.cues->burst_spawn(PORTAL_BURST, fighter.center_get(), 0, attack.owner); spot = teleport_spot(sim, fighter, attack.position, vec2_length(attack.position - fighter.center_get())); @@ -1898,9 +1906,9 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) float reach = stats.range * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1); for (int split = 0; split < splits; split++) { - float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.stat_get(STAT_SPREAD_CUT)) : 0; + float offset = splits > 1 ? split_offset(kind, split, splits, fighter.stat_get(STAT_SPREAD)) : 0; Vec2 tip = muzzle + vec2_polar(vec2_angle(fighter.aim) + offset, reach); - paths.push_back({.steps = {muzzle, tip}, .cone = VACUUM_CONE}); + paths.push_back({.steps = {muzzle, tip}, .cone = VACUUM_CONE * fighter.stat_get(STAT_SPREAD)}); } return paths; } @@ -1917,7 +1925,7 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) float reach = std::min(stats.range, TRAJECTORY_REACH); for (int split = 0; split < splits; split++) { - float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.stat_get(STAT_SPREAD_CUT)) : 0; + float offset = splits > 1 ? split_offset(kind, split, splits, fighter.stat_get(STAT_SPREAD)) : 0; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim); Vec2 velocity = isArrow ? aim * speed : aim; @@ -2241,13 +2249,20 @@ void Weapons::shots_drift(Sim& sim, Attack& source, float blow, float pull, floa if (&shot == &source || shot.isExplosion || !is_drifting(shot) || !is_in_cone(source, shot.position)) continue; const AttackDef& def = *shot.def(); + // A flail ball's chain comes with it, to the hand and weapon that blew it, for a while (or until that weapon's + // put away, or reels it in). + bool isChain = kind == ATTACK_FLAIL; bool isTied = def.isTethered || def.isArming || def.isWielded; - if (source.def()->isClaiming && shot.owner != source.owner && !isTied) + if (source.def()->isClaiming && shot.owner != source.owner && (!isTied || isChain)) { shot.ownerPrevious = shot.owner; shot.owner = source.owner; shot.claimTime = shot.time; - shot.key = -1; + shot.key = isChain ? source.key : -1; + shot.keyWeapon = source.weapon; + shot.hand = source.hand; + shot.isReturning = false; + shot.lifetime = isChain ? shot.life_get() + STOLEN_CHAIN_TIME : shot.lifetime; shot.isSecondary = false; shot.struck = 0; shot.hitCooldown = 0; diff --git a/src/weapon.h b/src/weapon.h index df8f394..9107c79 100644 --- a/src/weapon.h +++ b/src/weapon.h @@ -10,6 +10,8 @@ constexpr float RICOCHET_BONUS = 0.25f; // Hits for an attack that keeps hitting (a hit interval apart) as long as it's out. constexpr int HITS_ENDLESS = 1 << 30; constexpr int WHIP_RAYS = 9; +// How long a thrown flail ball stays out before it reels itself back in. +constexpr float FLAIL_LIFETIME = 15; class Fighter; struct Dummy; @@ -431,14 +433,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isProjectile = true}, .levels = { - {.normal = {.damage = 16, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60}, - .charged = {.damage = 34, .lifetime = 0.6f, .lunge = 140, .scale = 2.12f, .speed = 150}, + {.normal = {.damage = 16, .lifetime = 0.3f, .lunge = 70, .scale = 0.66f, .speed = 60}, + .charged = {.damage = 34, .lifetime = 0.6f, .lunge = 140, .scale = 1.06f, .speed = 150}, .cooldown = 0.3f}, - {.normal = {.damage = 23, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60}, - .charged = {.damage = 45, .lifetime = 0.6f, .lunge = 140, .scale = 2.5f, .speed = 150}, + {.normal = {.damage = 23, .lifetime = 0.3f, .lunge = 70, .scale = 0.81f, .speed = 60}, + .charged = {.damage = 45, .lifetime = 0.6f, .lunge = 140, .scale = 1.25f, .speed = 150}, .cooldown = 0.3f}, - {.normal = {.damage = 28, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60}, - .charged = {.damage = 55, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150}, + {.normal = {.damage = 28, .lifetime = 0.3f, .lunge = 70, .scale = 0.9525f, .speed = 60}, + .charged = {.damage = 55, .lifetime = 0.6f, .lunge = 140, .scale = 1.44f, .speed = 150}, .cooldown = 0.3f}, }, .bot = {.rangeMax = 48}, @@ -556,13 +558,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .isInstant = true}, .levels = { - {.normal = {.damage = 24, .lifetime = 0.38f, .speed = 950}, + {.normal = {.damage = 12, .lifetime = 0.38f, .speed = 475}, .charged = {.area = 6, .damage = 46, .range = 125}, .cooldown = 0.4f}, - {.normal = {.damage = 28, .lifetime = 0.38f, .speed = 1175}, + {.normal = {.damage = 14, .lifetime = 0.38f, .speed = 588}, .charged = {.area = 7, .damage = 70, .range = 155}, .cooldown = 0.4f}, - {.normal = {.damage = 32, .lifetime = 0.38f, .speed = 1400}, + {.normal = {.damage = 16, .lifetime = 0.38f, .speed = 700}, .charged = {.area = 8, .damage = 93, .range = 185}, .cooldown = 0.4f}, }, @@ -606,13 +608,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .levels = { {.normal = {.damage = 11, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518}, - .charged = {.damage = 35, .lifetime = LIFETIME_INFINITE, .range = 150, .scale = 1, .speed = 450}, + .charged = {.damage = 35, .lifetime = FLAIL_LIFETIME, .range = 150, .scale = 1, .speed = 450}, .cooldown = 0.5f}, {.normal = {.damage = 15, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644}, - .charged = {.damage = 44, .lifetime = LIFETIME_INFINITE, .range = 175, .scale = 1.5f, .speed = 450}, + .charged = {.damage = 44, .lifetime = FLAIL_LIFETIME, .range = 175, .scale = 1.5f, .speed = 450}, .cooldown = 0.5f}, {.normal = {.damage = 19, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738}, - .charged = {.damage = 51, .lifetime = LIFETIME_INFINITE, .range = 200, .scale = 2, .speed = 450}, + .charged = {.damage = 51, .lifetime = FLAIL_LIFETIME, .range = 200, .scale = 2, .speed = 450}, .cooldown = 0.5f}, }, .bot = {.rangeMax = 60}, @@ -1377,6 +1379,8 @@ struct Attack // When an arming attack came to rest (see is_armed), never until it has. float restTime = LIFETIME_INFINITE; float scale = 1; + // Its owner's spread when it set off (see STAT_SPREAD), widening a cone. + float spreading = 1; // A crawling bolt's side its surface is on: 1 for its right (as it heads), -1 for its left. float side = 0; // A bouncing shot's slope under it, carried between ticks with whether it's on the ground (see @@ -1394,6 +1398,9 @@ struct Attack Uint32 struck = 0; int weapon = 0; int key = -1; + // The weapon whose slot (key) keeps it out while it's tied to its owner (a mine, a flail ball or a grapple): its + // own, or the stealer's for a flail ball taken by an airblast. + int keyWeapon = -1; // The hand it came from: the main hand (0) or a Dual Wield off-hand (1 up). int hand = 0; AttackVariant variant = VARIANT_NORMAL; @@ -1499,10 +1506,10 @@ inline bool is_cone(const AttackDef& def) return def.shape == SHAPE_CONE; } -// How wide an attack's cone is (radians either side of its aim), or 0 for one without. +// How wide an attack's cone is (radians either side of its aim, wider with more spread), or 0 for one without. inline float cone_get(Attack& attack) { - return is_cone(*attack.def()) ? VACUUM_CONE : 0; + return is_cone(*attack.def()) ? VACUUM_CONE * attack.spreading : 0; } // How far an attack's cone reaches right now: a Vacuum's suck grows out as it spins up (its length, 0 to 1) and