Lobby browser, names and passwords, The Good Stuff, smarter queues, and a balance pass

- Lobbies: a name (default "Lobby <code>"), 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.
This commit is contained in:
shweet committed 2026-11-10 23:01:32 -05:00
1 parent af41b53717
commit 37560547d4
17 files changed
+666 -277

No files matched your search

+117 -113
View File
@@ -12,22 +12,23 @@
{"name": "Hourglass", "crop": [96, 0], "size": [24, 24], "origin": "center"}, {"name": "Hourglass", "crop": [96, 0], "size": [24, 24], "origin": "center"},
{"name": "Enlarger", "crop": [120, 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": "Diazepam", "crop": [0, 24], "size": [24, 24], "origin": "center"},
{"name": "Splitter", "crop": [24, 24], "size": [24, 24], "origin": "center"}, {"name": "The Good Stuff", "crop": [24, 24], "size": [24, 24], "origin": "center"},
{"name": "Doubler", "crop": [48, 24], "size": [24, 24], "origin": "center"}, {"name": "Splitter", "crop": [48, 24], "size": [24, 24], "origin": "center"},
{"name": "Dual Wield", "crop": [72, 24], "size": [24, 24], "origin": "center"}, {"name": "Doubler", "crop": [72, 24], "size": [24, 24], "origin": "center"},
{"name": "Ricochet", "crop": [96, 24], "size": [24, 24], "origin": "center"}, {"name": "Dual Wield", "crop": [96, 24], "size": [24, 24], "origin": "center"},
{"name": "Tesla Coil", "crop": [120, 24], "size": [24, 24], "origin": "center"}, {"name": "Ricochet", "crop": [120, 24], "size": [24, 24], "origin": "center"},
{"name": "Recoil Coil", "crop": [0, 48], "size": [24, 24], "origin": "center"}, {"name": "Tesla Coil", "crop": [0, 48], "size": [24, 24], "origin": "center"},
{"name": "Shock Absorber", "crop": [24, 48], "size": [24, 24], "origin": "center"}, {"name": "Recoil Coil", "crop": [24, 48], "size": [24, 24], "origin": "center"},
{"name": "Shrinker", "crop": [48, 48], "size": [24, 24], "origin": "center"}, {"name": "Shock Absorber", "crop": [48, 48], "size": [24, 24], "origin": "center"},
{"name": "Embiggener", "crop": [72, 48], "size": [24, 24], "origin": "center"}, {"name": "Shrinker", "crop": [72, 48], "size": [24, 24], "origin": "center"},
{"name": "Pebble Helmet", "crop": [96, 48], "size": [24, 24], "origin": "center"}, {"name": "Embiggener", "crop": [96, 48], "size": [24, 24], "origin": "center"},
{"name": "Boots", "crop": [120, 48], "size": [24, 24], "origin": "center"}, {"name": "Pebble Helmet", "crop": [120, 48], "size": [24, 24], "origin": "center"},
{"name": "Speedometer", "crop": [0, 72], "size": [24, 24], "origin": "center"}, {"name": "Boots", "crop": [0, 72], "size": [24, 24], "origin": "center"},
{"name": "Wings", "crop": [24, 72], "size": [24, 24], "origin": "center"}, {"name": "Speedometer", "crop": [24, 72], "size": [24, 24], "origin": "center"},
{"name": "Feather", "crop": [48, 72], "size": [24, 24], "origin": "center"}, {"name": "Wings", "crop": [48, 72], "size": [24, 24], "origin": "center"},
{"name": "Iron Weights", "crop": [72, 72], "size": [24, 24], "origin": "center"}, {"name": "Feather", "crop": [72, 72], "size": [24, 24], "origin": "center"},
{"name": "Climb Claw", "crop": [96, 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": "Gloves Small", "crop": [0, 96], "size": [16, 16], "origin": "center"},
{"name": "Energy Drink Small", "crop": [16, 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"}, {"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": "Hourglass Small", "crop": [64, 96], "size": [16, 16], "origin": "center"},
{"name": "Enlarger Small", "crop": [80, 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": "Diazepam Small", "crop": [0, 112], "size": [16, 16], "origin": "center"},
{"name": "Splitter Small", "crop": [16, 112], "size": [16, 16], "origin": "center"}, {"name": "The Good Stuff Small", "crop": [16, 112], "size": [16, 16], "origin": "center"},
{"name": "Doubler Small", "crop": [32, 112], "size": [16, 16], "origin": "center"}, {"name": "Splitter Small", "crop": [32, 112], "size": [16, 16], "origin": "center"},
{"name": "Dual Wield Small", "crop": [48, 112], "size": [16, 16], "origin": "center"}, {"name": "Doubler Small", "crop": [48, 112], "size": [16, 16], "origin": "center"},
{"name": "Ricochet Small", "crop": [64, 112], "size": [16, 16], "origin": "center"}, {"name": "Dual Wield Small", "crop": [64, 112], "size": [16, 16], "origin": "center"},
{"name": "Tesla Coil Small", "crop": [80, 112], "size": [16, 16], "origin": "center"}, {"name": "Ricochet Small", "crop": [80, 112], "size": [16, 16], "origin": "center"},
{"name": "Recoil Coil Small", "crop": [0, 128], "size": [16, 16], "origin": "center"}, {"name": "Tesla Coil Small", "crop": [0, 128], "size": [16, 16], "origin": "center"},
{"name": "Shock Absorber Small", "crop": [16, 128], "size": [16, 16], "origin": "center"}, {"name": "Recoil Coil Small", "crop": [16, 128], "size": [16, 16], "origin": "center"},
{"name": "Shrinker Small", "crop": [32, 128], "size": [16, 16], "origin": "center"}, {"name": "Shock Absorber Small", "crop": [32, 128], "size": [16, 16], "origin": "center"},
{"name": "Embiggener Small", "crop": [48, 128], "size": [16, 16], "origin": "center"}, {"name": "Shrinker Small", "crop": [48, 128], "size": [16, 16], "origin": "center"},
{"name": "Pebble Helmet Small", "crop": [64, 128], "size": [16, 16], "origin": "center"}, {"name": "Embiggener Small", "crop": [64, 128], "size": [16, 16], "origin": "center"},
{"name": "Boots Small", "crop": [80, 128], "size": [16, 16], "origin": "center"}, {"name": "Pebble Helmet Small", "crop": [80, 128], "size": [16, 16], "origin": "center"},
{"name": "Speedometer Small", "crop": [0, 144], "size": [16, 16], "origin": "center"}, {"name": "Boots Small", "crop": [0, 144], "size": [16, 16], "origin": "center"},
{"name": "Wings Small", "crop": [16, 144], "size": [16, 16], "origin": "center"}, {"name": "Speedometer Small", "crop": [16, 144], "size": [16, 16], "origin": "center"},
{"name": "Feather Small", "crop": [32, 144], "size": [16, 16], "origin": "center"}, {"name": "Wings Small", "crop": [32, 144], "size": [16, 16], "origin": "center"},
{"name": "Iron Weights Small", "crop": [48, 144], "size": [16, 16], "origin": "center"}, {"name": "Feather Small", "crop": [48, 144], "size": [16, 16], "origin": "center"},
{"name": "Climb Claw Small", "crop": [64, 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": "Gloves Tiny", "crop": [0, 160], "size": [8, 8], "origin": "center"},
{"name": "Energy Drink Tiny", "crop": [8, 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"}, {"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": "Hourglass Tiny", "crop": [32, 160], "size": [8, 8], "origin": "center"},
{"name": "Enlarger Tiny", "crop": [40, 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": "Diazepam Tiny", "crop": [0, 168], "size": [8, 8], "origin": "center"},
{"name": "Splitter Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"}, {"name": "The Good Stuff Tiny", "crop": [8, 168], "size": [8, 8], "origin": "center"},
{"name": "Doubler Tiny", "crop": [16, 168], "size": [8, 8], "origin": "center"}, {"name": "Splitter Tiny", "crop": [16, 168], "size": [8, 8], "origin": "center"},
{"name": "Dual Wield Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"}, {"name": "Doubler Tiny", "crop": [24, 168], "size": [8, 8], "origin": "center"},
{"name": "Ricochet Tiny", "crop": [32, 168], "size": [8, 8], "origin": "center"}, {"name": "Dual Wield Tiny", "crop": [32, 168], "size": [8, 8], "origin": "center"},
{"name": "Tesla Coil Tiny", "crop": [40, 168], "size": [8, 8], "origin": "center"}, {"name": "Ricochet Tiny", "crop": [40, 168], "size": [8, 8], "origin": "center"},
{"name": "Recoil Coil Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"}, {"name": "Tesla Coil Tiny", "crop": [0, 176], "size": [8, 8], "origin": "center"},
{"name": "Shock Absorber Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"}, {"name": "Recoil Coil Tiny", "crop": [8, 176], "size": [8, 8], "origin": "center"},
{"name": "Shrinker Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"}, {"name": "Shock Absorber Tiny", "crop": [16, 176], "size": [8, 8], "origin": "center"},
{"name": "Embiggener Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"}, {"name": "Shrinker Tiny", "crop": [24, 176], "size": [8, 8], "origin": "center"},
{"name": "Pebble Helmet Tiny", "crop": [32, 176], "size": [8, 8], "origin": "center"}, {"name": "Embiggener Tiny", "crop": [32, 176], "size": [8, 8], "origin": "center"},
{"name": "Boots Tiny", "crop": [40, 176], "size": [8, 8], "origin": "center"}, {"name": "Pebble Helmet Tiny", "crop": [40, 176], "size": [8, 8], "origin": "center"},
{"name": "Speedometer Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"}, {"name": "Boots Tiny", "crop": [0, 184], "size": [8, 8], "origin": "center"},
{"name": "Wings Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"}, {"name": "Speedometer Tiny", "crop": [8, 184], "size": [8, 8], "origin": "center"},
{"name": "Feather Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"}, {"name": "Wings Tiny", "crop": [16, 184], "size": [8, 8], "origin": "center"},
{"name": "Iron Weights Tiny", "crop": [24, 184], "size": [8, 8], "origin": "center"}, {"name": "Feather Tiny", "crop": [24, 184], "size": [8, 8], "origin": "center"},
{"name": "Climb Claw Tiny", "crop": [32, 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": [ "layers": [{"name": "sprite"}],
{"name": "sprite"}
],
"defaultAnimation": "Gloves", "defaultAnimation": "Gloves",
"animations": { "animations": {
"Gloves": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]}, "Gloves": {"layers": [{"layer": 0, "frames": [{"region": 0}]}]},
@@ -89,67 +90,70 @@
"Hourglass": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]}, "Hourglass": {"layers": [{"layer": 0, "frames": [{"region": 4}]}]},
"Enlarger": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]}, "Enlarger": {"layers": [{"layer": 0, "frames": [{"region": 5}]}]},
"Diazepam": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]}, "Diazepam": {"layers": [{"layer": 0, "frames": [{"region": 6}]}]},
"Splitter": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]}, "The Good Stuff": {"layers": [{"layer": 0, "frames": [{"region": 7}]}]},
"Doubler": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]}, "Splitter": {"layers": [{"layer": 0, "frames": [{"region": 8}]}]},
"Dual Wield": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]}, "Doubler": {"layers": [{"layer": 0, "frames": [{"region": 9}]}]},
"Ricochet": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]}, "Dual Wield": {"layers": [{"layer": 0, "frames": [{"region": 10}]}]},
"Tesla Coil": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]}, "Ricochet": {"layers": [{"layer": 0, "frames": [{"region": 11}]}]},
"Recoil Coil": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]}, "Tesla Coil": {"layers": [{"layer": 0, "frames": [{"region": 12}]}]},
"Shock Absorber": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]}, "Recoil Coil": {"layers": [{"layer": 0, "frames": [{"region": 13}]}]},
"Shrinker": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]}, "Shock Absorber": {"layers": [{"layer": 0, "frames": [{"region": 14}]}]},
"Embiggener": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]}, "Shrinker": {"layers": [{"layer": 0, "frames": [{"region": 15}]}]},
"Pebble Helmet": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]}, "Embiggener": {"layers": [{"layer": 0, "frames": [{"region": 16}]}]},
"Boots": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]}, "Pebble Helmet": {"layers": [{"layer": 0, "frames": [{"region": 17}]}]},
"Speedometer": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]}, "Boots": {"layers": [{"layer": 0, "frames": [{"region": 18}]}]},
"Wings": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]}, "Speedometer": {"layers": [{"layer": 0, "frames": [{"region": 19}]}]},
"Feather": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]}, "Wings": {"layers": [{"layer": 0, "frames": [{"region": 20}]}]},
"Iron Weights": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]}, "Feather": {"layers": [{"layer": 0, "frames": [{"region": 21}]}]},
"Climb Claw": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]}, "Iron Weights": {"layers": [{"layer": 0, "frames": [{"region": 22}]}]},
"Gloves Small": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]}, "Climb Claw": {"layers": [{"layer": 0, "frames": [{"region": 23}]}]},
"Energy Drink Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]}, "Gloves Small": {"layers": [{"layer": 0, "frames": [{"region": 24}]}]},
"Boxing Glove Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]}, "Energy Drink Small": {"layers": [{"layer": 0, "frames": [{"region": 25}]}]},
"Accelerator Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]}, "Boxing Glove Small": {"layers": [{"layer": 0, "frames": [{"region": 26}]}]},
"Hourglass Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]}, "Accelerator Small": {"layers": [{"layer": 0, "frames": [{"region": 27}]}]},
"Enlarger Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]}, "Hourglass Small": {"layers": [{"layer": 0, "frames": [{"region": 28}]}]},
"Diazepam Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]}, "Enlarger Small": {"layers": [{"layer": 0, "frames": [{"region": 29}]}]},
"Splitter Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]}, "Diazepam Small": {"layers": [{"layer": 0, "frames": [{"region": 30}]}]},
"Doubler Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]}, "The Good Stuff Small": {"layers": [{"layer": 0, "frames": [{"region": 31}]}]},
"Dual Wield Small": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]}, "Splitter Small": {"layers": [{"layer": 0, "frames": [{"region": 32}]}]},
"Ricochet Small": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]}, "Doubler Small": {"layers": [{"layer": 0, "frames": [{"region": 33}]}]},
"Tesla Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]}, "Dual Wield Small": {"layers": [{"layer": 0, "frames": [{"region": 34}]}]},
"Recoil Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]}, "Ricochet Small": {"layers": [{"layer": 0, "frames": [{"region": 35}]}]},
"Shock Absorber Small": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]}, "Tesla Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 36}]}]},
"Shrinker Small": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]}, "Recoil Coil Small": {"layers": [{"layer": 0, "frames": [{"region": 37}]}]},
"Embiggener Small": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]}, "Shock Absorber Small": {"layers": [{"layer": 0, "frames": [{"region": 38}]}]},
"Pebble Helmet Small": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]}, "Shrinker Small": {"layers": [{"layer": 0, "frames": [{"region": 39}]}]},
"Boots Small": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]}, "Embiggener Small": {"layers": [{"layer": 0, "frames": [{"region": 40}]}]},
"Speedometer Small": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]}, "Pebble Helmet Small": {"layers": [{"layer": 0, "frames": [{"region": 41}]}]},
"Wings Small": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]}, "Boots Small": {"layers": [{"layer": 0, "frames": [{"region": 42}]}]},
"Feather Small": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]}, "Speedometer Small": {"layers": [{"layer": 0, "frames": [{"region": 43}]}]},
"Iron Weights Small": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]}, "Wings Small": {"layers": [{"layer": 0, "frames": [{"region": 44}]}]},
"Climb Claw Small": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]}, "Feather Small": {"layers": [{"layer": 0, "frames": [{"region": 45}]}]},
"Gloves Tiny": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]}, "Iron Weights Small": {"layers": [{"layer": 0, "frames": [{"region": 46}]}]},
"Energy Drink Tiny": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]}, "Climb Claw Small": {"layers": [{"layer": 0, "frames": [{"region": 47}]}]},
"Boxing Glove Tiny": {"layers": [{"layer": 0, "frames": [{"region": 48}]}]}, "Gloves Tiny": {"layers": [{"layer": 0, "frames": [{"region": 48}]}]},
"Accelerator Tiny": {"layers": [{"layer": 0, "frames": [{"region": 49}]}]}, "Energy Drink Tiny": {"layers": [{"layer": 0, "frames": [{"region": 49}]}]},
"Hourglass Tiny": {"layers": [{"layer": 0, "frames": [{"region": 50}]}]}, "Boxing Glove Tiny": {"layers": [{"layer": 0, "frames": [{"region": 50}]}]},
"Enlarger Tiny": {"layers": [{"layer": 0, "frames": [{"region": 51}]}]}, "Accelerator Tiny": {"layers": [{"layer": 0, "frames": [{"region": 51}]}]},
"Diazepam Tiny": {"layers": [{"layer": 0, "frames": [{"region": 52}]}]}, "Hourglass Tiny": {"layers": [{"layer": 0, "frames": [{"region": 52}]}]},
"Splitter Tiny": {"layers": [{"layer": 0, "frames": [{"region": 53}]}]}, "Enlarger Tiny": {"layers": [{"layer": 0, "frames": [{"region": 53}]}]},
"Doubler Tiny": {"layers": [{"layer": 0, "frames": [{"region": 54}]}]}, "Diazepam Tiny": {"layers": [{"layer": 0, "frames": [{"region": 54}]}]},
"Dual Wield Tiny": {"layers": [{"layer": 0, "frames": [{"region": 55}]}]}, "The Good Stuff Tiny": {"layers": [{"layer": 0, "frames": [{"region": 55}]}]},
"Ricochet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 56}]}]}, "Splitter Tiny": {"layers": [{"layer": 0, "frames": [{"region": 56}]}]},
"Tesla Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 57}]}]}, "Doubler Tiny": {"layers": [{"layer": 0, "frames": [{"region": 57}]}]},
"Recoil Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 58}]}]}, "Dual Wield Tiny": {"layers": [{"layer": 0, "frames": [{"region": 58}]}]},
"Shock Absorber Tiny": {"layers": [{"layer": 0, "frames": [{"region": 59}]}]}, "Ricochet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 59}]}]},
"Shrinker Tiny": {"layers": [{"layer": 0, "frames": [{"region": 60}]}]}, "Tesla Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 60}]}]},
"Embiggener Tiny": {"layers": [{"layer": 0, "frames": [{"region": 61}]}]}, "Recoil Coil Tiny": {"layers": [{"layer": 0, "frames": [{"region": 61}]}]},
"Pebble Helmet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 62}]}]}, "Shock Absorber Tiny": {"layers": [{"layer": 0, "frames": [{"region": 62}]}]},
"Boots Tiny": {"layers": [{"layer": 0, "frames": [{"region": 63}]}]}, "Shrinker Tiny": {"layers": [{"layer": 0, "frames": [{"region": 63}]}]},
"Speedometer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 64}]}]}, "Embiggener Tiny": {"layers": [{"layer": 0, "frames": [{"region": 64}]}]},
"Wings Tiny": {"layers": [{"layer": 0, "frames": [{"region": 65}]}]}, "Pebble Helmet Tiny": {"layers": [{"layer": 0, "frames": [{"region": 65}]}]},
"Feather Tiny": {"layers": [{"layer": 0, "frames": [{"region": 66}]}]}, "Boots Tiny": {"layers": [{"layer": 0, "frames": [{"region": 66}]}]},
"Iron Weights Tiny": {"layers": [{"layer": 0, "frames": [{"region": 67}]}]}, "Speedometer Tiny": {"layers": [{"layer": 0, "frames": [{"region": 67}]}]},
"Climb Claw Tiny": {"layers": [{"layer": 0, "frames": [{"region": 68}]}]} "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}]}]}
} }
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

+54 -5
View File
@@ -100,11 +100,15 @@ enum LeaveReason
LEAVE_KICKED LEAVE_KICKED
}; };
// A lobby: its members in the order they joined (the first hosting), and its rooms, the sandbox first. // A lobby: its name, the password to join it (empty for none), whether the lobby list shows it, its members in the
// order they joined (the first hosting), and its rooms, the sandbox first.
struct Lobby struct Lobby
{ {
std::string name;
std::string password;
std::vector<int> members; std::vector<int> members;
std::vector<int> rooms; std::vector<int> 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 // 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) + list += (list.empty() ? "" : ",") + std::string("{\"id\":") + std::to_string(other) +
",\"name\":" + json_quote(members[other].name) + "}"; ",\"name\":" + json_quote(members[other].name) + "}";
send(id, "{\"type\":\"welcome\",\"id\":" + std::to_string(id) + ",\"code\":" + json_quote(code) + 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) for (int other : lobby.members)
if (other != id) if (other != id)
send(other, "{\"type\":\"member_join\",\"id\":" + std::to_string(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 + "}"); ",\"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) static void text_handle(int id, const std::string& text)
{ {
simdjson::dom::element message; 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. // Answered, so proxies see the connection busy both ways.
if (type == "ping") if (type == "ping")
return send(id, "{\"type\":\"pong\"}"); return send(id, "{\"type\":\"pong\"}");
if (type == "create" || type == "join") if (type == "list" || type == "create" || type == "join")
{ {
int64_t version = 0; int64_t version = 0;
std::string_view name; std::string_view name;
if (message["version"].get(version) || version != PROTOCOL_VERSION) if (message["version"].get(version) || version != PROTOCOL_VERSION)
return error_send(id, "Different game version"); return error_send(id, "Different game version");
if (type == "list")
return lobbies_send(id);
if (!members[id].code.empty()) if (!members[id].code.empty())
return error_send(id, "Already in a lobby"); return error_send(id, "Already in a lobby");
members[id].name = name_clean(message["name"].get(name) ? "" : name); 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(); std::string code = code_make();
if (code.empty()) if (code.empty())
return error_send(id, "No free lobby codes"); 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); return lobby_enter(id, code);
} }
std::string_view raw; 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"); return error_send(id, "No lobby with that code");
if ((int)lobby->second.members.size() >= LOBBY_MEMBERS_MAX) if ((int)lobby->second.members.size() >= LOBBY_MEMBERS_MAX)
return error_send(id, "That lobby is full"); 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); return lobby_enter(id, code);
} }
if (type == "leave") 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]]); 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]])); 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.color = lobby.colors[seats[k]];
members[players[k]].profile.fashion = lobby.fashions[seats[k]];
} }
int serial = room_open(sandbox.code); int serial = room_open(sandbox.code);
Room& game = rooms[serial]; Room& game = rooms[serial];
+4 -1
View File
@@ -364,7 +364,7 @@ void Chat::command(Game& game, const std::string& text)
system("Unknown command. Type /help for a list of commands."); 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) Rect Chat::log_area_get(Game& game)
{ {
float bottom = CANVAS_HEIGHT - HOTBAR_MARGIN; 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++) for (int k = 0; k < rivals.count; k++)
bottom = std::min(bottom, rivals.cards[k].y - CHAT_GAP); 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}; return {CANVAS_WIDTH - HOTBAR_MARGIN - CHAT_WIDTH, 0, CHAT_WIDTH, bottom};
} }
+16 -13
View File
@@ -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" // 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). // 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. // A launch option, if given: from the page's ?<query>= in the browser, or <flag> <value> on the command line. A lobby
static std::string lobby_requested(int argc, char** argv) // 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 #ifdef SDL_PLATFORM_EMSCRIPTEN
char* code = (char*)EM_ASM_PTR({ char* found = (char*)EM_ASM_PTR(
var code = new URLSearchParams(location.search).get('lobby'); {
return code ? stringToNewUTF8(code) : 0; var value = new URLSearchParams(location.search).get(UTF8ToString($0));
}); return value ? stringToNewUTF8(value) : 0;
std::string lobby = code ? code : ""; },
free(code); query);
return lobby; std::string option = found ? found : "";
free(found);
return option;
#else #else
for (int i = 1; i + 1 < argc; i++) 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 argv[i + 1];
return ""; return "";
#endif #endif
@@ -283,11 +286,11 @@ int main(int argc, char** argv)
game.renderer.atlas_upload(); game.renderer.atlas_upload();
game.settings.apply(game); game.settings.apply(game);
game.settings.restore(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()) if (!lobby.empty())
{ {
game.net.lobby_join(game, lobby.c_str()); game.net.lobby_join(game, lobby, launch_option_get(argc, argv, "pass", "--pass"));
game.menu.online_open(); game.menu.online_open(lobby);
} }
game.camera = camera_target_get(game); game.camera = camera_target_get(game);
game.cameraPrevious = game.camera; game.cameraPrevious = game.camera;
+42 -32
View File
@@ -620,15 +620,17 @@ void Match::sandbox_return(Sim& sim)
fighter.state_reset(sim, 1); 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 // Online, opens a fighter's queued game: an area on flat ground with a slot for everyone it's for, centered on the
// as there's room (on the floor below it, or the nearest stretch of floor to either side that's flat, clear overhead // fighter where there's room, with the fighter standing still in its middle, readied up. It goes on the floor at or
// and not another queue's), with the fighter standing still at its left end, readied up. // 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) void Match::queue_open(Sim& sim, int fighter, const Rules& wanted)
{ {
World& world = sim.world; World& world = sim.world;
Queue& queue = queues[fighter]; Queue& queue = queues[fighter];
queue = {.rules = rules_clean(wanted), .isActive = true}; queue = {.rules = rules_clean(wanted), .isActive = true};
float width = queue.rules.players * QUEUE_SLOT_WIDTH + QUEUE_MARGIN * 2; 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(); Vec2 size = world.size_get();
Fighter& queuer = sim.fighters[fighter]; Fighter& queuer = sim.fighters[fighter];
Vec2 feet = {queuer.body.box.x + queuer.body.box.w / 2, queuer.body.box.y + queuer.body.box.h}; 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 y;
return -1.0f; return -1.0f;
}; };
auto is_fitting = [&](float left, float floor) { // Whether a queue's area could stand over a point on a floor: flat ground, clear overhead, nobody else's queue.
if (left < 0 || left + width > size.x || floor < QUEUE_HEIGHT) 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; return false;
for (float x = left; x <= left + width; x += QUEUE_PROBE_STEP) for (float up = QUEUE_PROBE_STEP; up <= QUEUE_HEIGHT; up += QUEUE_PROBE_STEP)
{ if (world.is_solid_at({x, floor - up}))
if (!world.is_blocking_at({x, floor + 1}) || world.is_blocking_at({x, floor - 1}))
return false; 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++) 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 false;
}
return true; return true;
}; };
float start = std::round(feet.x - QUEUE_MARGIN - QUEUE_SLOT_WIDTH / 2); // 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
queue.area = {start, feet.y - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; // middle where the stretch allows, if the stretch is long enough for it squeezed.
for (float offset = 0; offset < size.x; offset += QUEUE_PROBE_STEP) auto place = [&](float x, float floor, float middle) {
{ if (floor < QUEUE_HEIGHT || !is_standable(x, floor))
bool isFound = false; return false;
for (float left : {start + offset, start - offset}) float left = x;
{ float right = x;
float floor = floor_find(left + width / 2, std::round(feet.y - TILE_SIZE)); while (left - x > -width && is_standable(left - QUEUE_PROBE_STEP, floor))
if (floor < 0 || !is_fitting(left, floor)) left -= QUEUE_PROBE_STEP;
continue; while (right - x < width && is_standable(right + QUEUE_PROBE_STEP, floor))
queue.area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; right += QUEUE_PROBE_STEP;
isFound = true; float span = std::min(width, right - left);
break; if (span < narrowest)
} return false;
if (isFound) float center = std::clamp(middle, left + span / 2, right - span / 2);
break; queue.area = {std::round(center - span / 2), floor - QUEUE_HEIGHT, std::round(span), QUEUE_HEIGHT};
} return true;
queuer.position_set(sim, {queue.area.x + QUEUE_MARGIN + QUEUE_SLOT_WIDTH / 2, queue.area.y + queue.area.h}); };
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; }); std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == fighter; });
queuedIn[fighter] = fighter; queuedIn[fighter] = fighter;
isQueueReady[fighter] = false; isQueueReady[fighter] = false;
+2
View File
@@ -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 // 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. // how far below its floor feet still count as in it.
constexpr float QUEUE_SLOT_WIDTH = 24; 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_MARGIN = 6;
constexpr float QUEUE_HEIGHT = 40; constexpr float QUEUE_HEIGHT = 40;
constexpr float QUEUE_PROBE_STEP = 4; constexpr float QUEUE_PROBE_STEP = 4;
+220 -46
View File
@@ -13,6 +13,15 @@ constexpr float BUTTON_WIDTH = 120;
constexpr float CODE_FIELD_WIDTH = 60; constexpr float CODE_FIELD_WIDTH = 60;
constexpr float CODE_SCALE = 2; constexpr float CODE_SCALE = 2;
constexpr float JOIN_BUTTON_WIDTH = 40; 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 float COPY_BUTTON_WIDTH = 80;
constexpr Uint64 COPIED_SHOWN = 1500; constexpr Uint64 COPIED_SHOWN = 1500;
constexpr const char LOBBY_PAGE_DEFAULT[] = "https://shweetz.net/smack-a-brother/"; 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 // 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 // one to stop spectating), and in the sandbox on desktop, Quit. Each cell is as wide as its name (a button's longest
// padding, with the room left shared out evenly, so the names sit evenly spaced whatever is showing. Tab names are big // label) and padding, with the room left shared out evenly, so the names sit evenly spaced whatever is showing. Tab
// when they all fit. // names are big when they all fit.
HeaderLayout Menu::header_layout_get(Game& game, Rect area) HeaderLayout Menu::header_layout_get(Game& game, Rect area)
{ {
Text& text = game.text; Text& text = game.text;
@@ -464,7 +473,8 @@ HeaderLayout Menu::header_layout_get(Game& game, Rect area)
header.buttons[header.buttonCount++] = BUTTON_FORFEIT; header.buttons[header.buttonCount++] = BUTTON_FORFEIT;
} }
#ifndef __EMSCRIPTEN__ #ifndef __EMSCRIPTEN__
header.buttons[header.buttonCount++] = BUTTON_QUIT; if (match.state == MATCH_PRACTICE)
header.buttons[header.buttonCount++] = BUTTON_QUIT;
#endif #endif
float buttons = 0; float buttons = 0;
@@ -790,10 +800,188 @@ static Color member_color_get(Game& game, int member)
return color >= 0 ? PALETTE[color] : game.ui.tint; 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 // Text made safe for a link's query (letters and digits as they are, everything else as %XX).
// why it failed), and under it where lobbies are hosted. In one: its code (to read out or copy to friends) and leaving, static std::string url_encode(const std::string& text)
// queueing a game in its sandbox, and everyone in it with how their connection is doing, then the games being played, {
// to spectate. 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) void Menu::online_draw(Game& game, Rect area)
{ {
Ui& ui = game.ui; Ui& ui = game.ui;
@@ -806,43 +994,22 @@ void Menu::online_draw(Game& game, Rect area)
}; };
if (net.state != NET_LOBBY) if (net.state != NET_LOBBY)
{ return lobbies_draw(game, area);
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);
// The online settings, as in Settings. // The lobby's name, big (cut short to fit), its code faded under it, then buttons to copy the code (or a link to
ui.separator_draw(game, {area.x, y, area.w, WIDGET_HEIGHT}, "Online"); // it, with its password when it has one) and to leave.
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.
char line[TOOLTIP_LENGTH]; char line[TOOLTIP_LENGTH];
SDL_snprintf(line, sizeof(line), "Lobby %s", net.code.c_str()); std::string name = net.lobbyName;
text.draw(game.renderer, line, {.color = ui.tint, .position = {area.x, y}, .scale = {CODE_SCALE, CODE_SCALE}}); while (!name.empty() && text.size_get((name + "...").c_str(), FONT_SMALL, CODE_SCALE).x > area.w &&
y += text.line_height_get(FONT_SMALL, CODE_SCALE) + PADDING; 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(); Uint64 now = SDL_GetTicks();
float third = std::floor((area.w - PADDING * 2) / 3); float third = std::floor((area.w - PADDING * 2) / 3);
for (int i = 0; i < 2; i++) for (int i = 0; i < 2; i++)
@@ -856,7 +1023,9 @@ void Menu::online_draw(Game& game, Rect area)
: "Copy Link", : "Copy Link",
i == 0 ? "copy code" : "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; copiedButton = i;
copiedUntil = now + COPIED_SHOWN; 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. // Opens the menu on the Multiplayer page, as joining a lobby from a link does, with its code ready to join again (and
void Menu::online_open() // its password asked for there, should it need one).
void Menu::online_open(const std::string& code)
{ {
isOpen = true; isOpen = true;
tab = TAB_PLAY; tab = TAB_PLAY;
page = PAGE_ONLINE; page = PAGE_ONLINE;
isMultiplayer = true; 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. // Opens the menu on the item tab a round's countdown lets fighters choose from.
+11 -1
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "net.h"
#include "common.h" #include "common.h"
#include "protocol.h" #include "protocol.h"
#include "ui.h" #include "ui.h"
@@ -93,7 +94,7 @@ public:
void update(Game& game); void update(Game& game);
void draw(Game& game); void draw(Game& game);
void choice_open(Game& game); void choice_open(Game& game);
void online_open(); void online_open(const std::string& code);
void targets_reset(Game& game); void targets_reset(Game& game);
void target_fill(Game& game); void target_fill(Game& game);
bool is_bars_open(); bool is_bars_open();
@@ -106,6 +107,7 @@ public:
private: private:
Page description; Page description;
Page lobbyPage; Page lobbyPage;
Page onlinePage;
int described = -1; int described = -1;
// A hotbar slot clicked to read about (its item shown instead of the selection) while still on the tab it was // 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. // clicked on, or -1.
@@ -130,6 +132,13 @@ private:
Rules queueRules = rules_get(MODE_DEATHMATCH); Rules queueRules = rules_get(MODE_DEATHMATCH);
// The lobby code typed on the Multiplayer page. // The lobby code typed on the Multiplayer page.
char joinCode[LOBBY_CODE_LENGTH + 1] = {}; 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. // Which lobby copy button (code or link) was last used, and until when it says so.
int copiedButton = -1; int copiedButton = -1;
Uint64 copiedUntil = 0; Uint64 copiedUntil = 0;
@@ -153,6 +162,7 @@ private:
void header_draw(Game& game, MenuLayout& layout); void header_draw(Game& game, MenuLayout& layout);
void items_draw(Game& game, MenuLayout& layout); void items_draw(Game& game, MenuLayout& layout);
void play_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 about_draw(Game& game, Rect area);
void rules_draw(Game& game, Rect area); void rules_draw(Game& game, Rect area);
void online_draw(Game& game, Rect area); void online_draw(Game& game, Rect area);
+80 -16
View File
@@ -35,6 +35,8 @@ struct Net::Impl
std::shared_ptr<rtc::WebSocket> socket; std::shared_ptr<rtc::WebSocket> socket;
PeerLink link; PeerLink link;
std::string pending; std::string pending;
// The state to wait in once the pending request goes (see request_send).
NetState waiting = NET_OFFLINE;
simdjson::dom::parser parser; simdjson::dom::parser parser;
Uint64 nextPing = 0; Uint64 nextPing = 0;
// The network simulator (SAB_NET_LAG milliseconds on everything sent, SAB_NET_LOSS percent of input packets // 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(); 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) + password = options.password;
",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}"); 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\":" + password = lobbyPassword;
json_quote(game.settings.name) + ",\"version\":" + std::to_string(PROTOCOL_VERSION) + "}"); 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. // Leaves the lobby, closing the WebRTC connection but keeping the socket to the server.
@@ -131,23 +149,32 @@ void Net::lobby_leave()
packets.clear(); packets.clear();
kicks.clear(); kicks.clear();
code.clear(); code.clear();
lobbyName.clear();
password.clear();
selfId = hostId = -1; selfId = hostId = -1;
state = NET_OFFLINE; state = NET_OFFLINE;
} }
// Sends a create or join, connecting to the server first if need be (it goes out once the socket opens). The // 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). // 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()) if (impl->socket && impl->socket->isOpen())
{ {
impl->socket->send(request); impl->socket->send(request);
state = NET_JOINING; state = waiting;
return; return;
} }
// A quiet request never takes the place of one waiting to join.
if (isQuiet && !impl->pending.empty())
return;
impl->pending = request; impl->pending = request;
state = NET_CONNECTING; impl->waiting = waiting;
state = isQuiet ? state : NET_CONNECTING;
if (impl->socket) if (impl->socket)
return; return;
impl->socket = std::make_shared<rtc::WebSocket>(); impl->socket = std::make_shared<rtc::WebSocket>();
@@ -164,7 +191,7 @@ void Net::request_send(Game& game, const std::string& request)
return; return;
impl->socket->send(impl->pending); impl->socket->send(impl->pending);
impl->pending.clear(); impl->pending.clear();
state = NET_JOINING; state = impl->waiting;
}); });
}); });
impl->socket->onMessage([this, shared](rtc::message_variant data) { 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(); lobby_leave();
impl->socket.reset(); impl->socket.reset();
impl->pending.clear(); impl->pending.clear();
if (error.empty()) std::string& failure = failure_get();
error = "Lost the connection to the server"; if (failure.empty())
failure = "Lost the connection to the server";
}); });
}); });
impl->socket->onError([this, shared](std::string message) { 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"); const char* url = SDL_getenv("SAB_SERVER_URL");
impl->socket->open(url ? url : game.settings.serverUrl); 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) NetMember* Net::member_get(int id)
{ {
for (NetMember& member : members) for (NetMember& member : members)
@@ -213,8 +247,8 @@ void Net::message_handle(const std::string& text)
if (type == "error") if (type == "error")
{ {
error = message["message"].get(field) ? "Server error" : std::string(field); failure_get() = message["message"].get(field) ? "Server error" : std::string(field);
if (state != NET_LOBBY) if (state != NET_LOBBY && !isListing)
state = NET_OFFLINE; state = NET_OFFLINE;
return; return;
} }
@@ -224,6 +258,7 @@ void Net::message_handle(const std::string& text)
selfId = message["id"].get(number) ? -1 : (int)number; selfId = message["id"].get(number) ? -1 : (int)number;
hostId = message["host"].get(number) ? -1 : (int)number; hostId = message["host"].get(number) ? -1 : (int)number;
code = message["code"].get(field) ? "" : std::string(field); code = message["code"].get(field) ? "" : std::string(field);
lobbyName = message["name"].get(field) ? "Lobby " + code : std::string(field);
simdjson::dom::array list; simdjson::dom::array list;
if (!message["members"].get(list)) if (!message["members"].get(list))
for (simdjson::dom::element entry : list) for (simdjson::dom::element entry : list)
@@ -237,6 +272,35 @@ void Net::message_handle(const std::string& text)
link_open(); link_open();
return; 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") if (type == "member_join")
{ {
NetMember member; NetMember member;
+42 -5
View File
@@ -15,8 +15,6 @@ enum NetState
NET_LOBBY 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. // 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 struct NetNotice
{ {
@@ -24,6 +22,34 @@ struct NetNotice
int member = -1; 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<std::pair<int, int>> 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 <code>"), 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 struct NetMember
{ {
int id = -1; int id = -1;
@@ -44,8 +70,9 @@ public:
Net(); Net();
~Net(); ~Net();
void lobby_create(Game& game); void lobby_create(Game& game, LobbyOptions& options);
void lobby_join(Game& game, const char* code); void lobbies_request(Game& game);
void lobby_join(Game& game, const std::string& code, const std::string& password);
void lobby_leave(); void lobby_leave();
void update(); void update();
void shutdown(); void shutdown();
@@ -58,6 +85,12 @@ public:
NetState state = NET_OFFLINE; NetState state = NET_OFFLINE;
std::string code; std::string code;
std::string error; 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<LobbyListing> listings;
std::string listError;
int selfId = -1; int selfId = -1;
int hostId = -1; int hostId = -1;
std::vector<NetMember> members; std::vector<NetMember> members;
@@ -72,7 +105,7 @@ private:
struct Impl; struct Impl;
std::unique_ptr<Impl> impl; std::unique_ptr<Impl> 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 send_now(const std::string& bytes, Delivery delivery);
void ping_send(); void ping_send();
void message_handle(const std::string& text); void message_handle(const std::string& text);
@@ -80,4 +113,8 @@ private:
void signal_handle(const std::string& payload); void signal_handle(const std::string& payload);
NetMember* member_get(int id); NetMember* member_get(int id);
void host_announce(int previous); 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;
}; };
+6 -1
View File
@@ -27,9 +27,14 @@
#include <string_view> #include <string_view>
#include <type_traits> #include <type_traits>
constexpr int PROTOCOL_VERSION = 9; constexpr int PROTOCOL_VERSION = 11;
constexpr int LOBBY_MEMBERS_MAX = 16; constexpr int LOBBY_MEMBERS_MAX = 16;
constexpr int LOBBY_CODE_LENGTH = 4; 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. // Letters only, without I and O, so codes are easy to read out and type.
constexpr const char LOBBY_CODE_LETTERS[] = "ABCDEFGHJKLMNPQRSTUVWXYZ"; constexpr const char LOBBY_CODE_LETTERS[] = "ABCDEFGHJKLMNPQRSTUVWXYZ";
constexpr int PLAYER_NAME_MAX = 24; constexpr int PLAYER_NAME_MAX = 24;
+12 -9
View File
@@ -14,6 +14,7 @@ enum TrinketId
TRINKET_HOURGLASS, TRINKET_HOURGLASS,
TRINKET_ENLARGER, TRINKET_ENLARGER,
TRINKET_DIAZEPAM, TRINKET_DIAZEPAM,
TRINKET_GOOD,
TRINKET_SPLITTER, TRINKET_SPLITTER,
TRINKET_DOUBLER, TRINKET_DOUBLER,
TRINKET_DUAL, TRINKET_DUAL,
@@ -65,7 +66,7 @@ struct TrinketStats
float slamArea = 0; float slamArea = 0;
float slamDamage = 0; float slamDamage = 0;
float speedDamage = 0; float speedDamage = 0;
float spreadCut = 0; float spread = 0;
}; };
// The stats, in the order tooltips list them. // The stats, in the order tooltips list them.
@@ -76,7 +77,7 @@ enum TrinketStat
STAT_PROJECTILE_SPEED, STAT_PROJECTILE_SPEED,
STAT_LIFETIME, STAT_LIFETIME,
STAT_ATTACK_SIZE, STAT_ATTACK_SIZE,
STAT_SPREAD_CUT, STAT_SPREAD,
STAT_COPIES, STAT_COPIES,
STAT_COPY_DAMAGE, STAT_COPY_DAMAGE,
STAT_REPEAT_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::projectileSpeed, "Projectile Speed", FORMAT_TIMES, STACK_SCALE, 1},
{&TrinketStats::lifetime, "Projectile Lifetime", FORMAT_TIMES, STACK_SCALE, 1}, {&TrinketStats::lifetime, "Projectile Lifetime", FORMAT_TIMES, STACK_SCALE, 1},
{&TrinketStats::attackSize, "Attack Size", 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::copies, "Copies", FORMAT_NUMBER, STACK_SCALE, 1},
{&TrinketStats::copyDamage, "Damage Each", FORMAT_TIMES, STACK_BEST}, {&TrinketStats::copyDamage, "Damage Each", FORMAT_TIMES, STACK_BEST},
{&TrinketStats::repeatDamage, "Repeat Damage", 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."}, "A noble spirit embiggens the\nsmallest projectile."},
{"Diazepam", {"Diazepam",
"Decreases spread on attacks.", "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?"}, "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", {"Splitter",
"Attacks split into smaller,\nless-damaging copies.", "Attacks split into smaller,\nless-damaging copies.",
{{.copies = 2, .copyDamage = 1 / 3.0f}, {{.copies = 2, .copyDamage = 1 / 3.0f},
@@ -229,7 +234,7 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = {
"Okay this is just Technology Zero."}, "Okay this is just Technology Zero."},
{"Recoil Coil", {"Recoil Coil",
"All attacks have additional\nrecoil.", "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..."}, "The forgotten sibling of Gravity\nand Speed..."},
{"Shock Absorber", {"Shock Absorber",
"Receive less knockback, and\nattacks incur less movement.", "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}}, {{.runSpeed = 1.3f}, {.runSpeed = 1.65f}, {.runSpeed = 2}},
"THE BOOT!"}, "THE BOOT!"},
{"Speedometer", {"Speedometer",
"Moving faster deals more damage.\nSpeed and acceleration\nincreased slightly.", "Moving faster deals more damage.",
{{.runSpeed = 1.05f, .speedDamage = 0.05f}, {{.speedDamage = 0.05f}, {.speedDamage = 0.1f}, {.speedDamage = 0.15f}},
{.runSpeed = 1.1f, .speedDamage = 0.1f},
{.runSpeed = 1.15f, .speedDamage = 0.15f}},
"When this baby hits 88 miles\nper hour..."}, "When this baby hits 88 miles\nper hour..."},
{"Wings", {"Wings",
"Dash in the air, following\nyour aim.", "Dash in the air, following\nyour aim.",
+5 -2
View File
@@ -223,6 +223,7 @@ constexpr Tip TIPS[] = {
{"Use the Grimoire's teleporting Charged Attack to escape danger!", "Grimoire"}, {"Use the Grimoire's teleporting Charged Attack to escape danger!", "Grimoire"},
{"Diazepam reduces Weapon spread, perfect for concentrated attacks!", "Diazepam"}, {"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"}, {"Splitter copies fan wider with each level!", "Splitter"},
{"The off-hand Weapon from Dual Wield is the one you last switched from!", "Dual Wield"}, {"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"}, {"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.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.w - 1, area.y, 1, area.h}, edge);
renderer.rect_draw({area.x, area.y, area.w, 1}, 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++) for (int k = 0; k < queue.rules.players; k++)
{ {
float x = std::round(area.x + QUEUE_MARGIN + k * QUEUE_SLOT_WIDTH + 2); float x = std::round(area.x + QUEUE_MARGIN + k * slot + 2);
renderer.rect_draw({x, area.y + area.h - 2, QUEUE_SLOT_WIDTH - 4, 2}, renderer.rect_draw({x, area.y + area.h - 2, std::round(slot) - 4, 2},
k < count ? color : color_shade(color, SECONDARY_SHADE)); k < count ? color : color_shade(color, SECONDARY_SHADE));
} }
+2 -2
View File
@@ -221,6 +221,8 @@ constexpr const char* HINT_KEYS[HINT_COUNT] = {"move", "jump", "down", "attack",
class Ui class Ui
{ {
public: public:
void tip_draw(Game& game, float y);
float tip_low_get();
Color tint = WHITE; Color tint = WHITE;
Color shade = GRAY; Color shade = GRAY;
int adjust = 0; int adjust = 0;
@@ -347,8 +349,6 @@ private:
void hints_update(Game& game); void hints_update(Game& game);
int tip_pick(Game& game); int tip_pick(Game& game);
void tips_update(Game& game); void tips_update(Game& game);
void tip_draw(Game& game, float y);
float tip_low_get();
void hints_draw(Game& game); 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, float alpha = 1);
void text_center_draw(Game& game, const char* text, float y, float scale, Color color); void text_center_draw(Game& game, const char* text, float y, float scale, Color color);
+32 -17
View File
@@ -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 // 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. // Ricochet bounce adds, as it takes aim and timing.
constexpr float REFLECT_BONUS = 1.5f; 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 SPEED_UNIT = 100;
constexpr float CONTACT_INTERVAL = 0.4f; constexpr float CONTACT_INTERVAL = 0.4f;
constexpr float WISP_GLIDE = 14; 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 gloves = fighter.stat_get(STAT_ATTACK_RATE);
float drink = fighter.stat_get(STAT_CHARGE_RATE); float drink = fighter.stat_get(STAT_CHARGE_RATE);
float accelerator = attack.isProjectile ? fighter.stat_get(STAT_PROJECTILE_SPEED) : 1; 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); float absorb = fighter.stat_get(STAT_MOVEMENT);
AttackSummary summary; AttackSummary summary;
@@ -714,8 +716,8 @@ AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, Attack
summary.isAreaChanged = enlarger != 1 || splits > 1; summary.isAreaChanged = enlarger != 1 || splits > 1;
summary.speed = attack.isProjectile ? stats.speed * accelerator : 0; summary.speed = attack.isProjectile ? stats.speed * accelerator : 0;
summary.isSpeedChanged = accelerator != 1; summary.isSpeedChanged = accelerator != 1;
summary.spread = attack.spread * steadiness; summary.spread = attack.spread * spreading;
summary.isSpreadChanged = steadiness != 1; summary.isSpreadChanged = spreading != 1;
summary.lunge = stats.lunge * absorb; summary.lunge = stats.lunge * absorb;
summary.isLungeChanged = absorb != 1; summary.isLungeChanged = absorb != 1;
summary.pieces = stats.volley * splits; 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. // A rope or chain is as long as its shot's flight carries it, so it lengthens as the flight does.
if (def.isTethered) if (def.isTethered)
stats.range *= hourglass * accelerator; 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; float jitter = def.spread * DEGREE * spread;
Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.rng.random_get(-jitter, jitter), 1); Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.rng.random_get(-jitter, jitter), 1);
float push = def.isInheriting ? push_get(fighter, aim) : 0; 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, .fade = stats.fade > 0 ? stats.fade * hourglass : lifetime,
.lifetime = lifetime, .lifetime = lifetime,
.scale = def.scale * stats.scale, .scale = def.scale * stats.scale,
.spreading = spread,
.bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0, .bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0,
.hitsLeft = def.hits, .hitsLeft = def.hits,
.owner = fighter.index, .owner = fighter.index,
.weapon = held, .weapon = held,
.key = key, .key = key,
.keyWeapon = held,
.hand = shot.hand, .hand = shot.hand,
.variant = variant, .variant = variant,
.isLeft = fighter.is_left(), .isLeft = fighter.is_left(),
@@ -1076,10 +1080,10 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
else if (def.path != PATH_OWN) else if (def.path != PATH_OWN)
flight_move(sim, attack); 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 // A mine, flail ball or grapple left out goes once its slot no longer holds the weapon that keeps it (which could
// could no longer set it off, reel it in or let it go), a mine fizzling with a puff. // no longer set it off, reel it in or let it go), a mine fizzling with a puff.
bool isLeftOut = def.isArming || def.isTethered; 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; attack.isDead = true;
if (def.isArming) if (def.isArming)
@@ -1349,8 +1353,11 @@ void Weapons::flight_move(Sim& sim, Attack& attack)
} }
attack.tip = start; attack.tip = start;
bool isOver = !attack.isDead && attack.life_get() >= attack.lifetime; // A tethered shot out of time reels itself back in (a flail ball, to whoever holds its chain now).
if (isOver && def.isBursting) 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); attack_explode(sim, attack);
else if (isOver) 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 // 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 // (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) void Weapons::teleport_move(Sim& sim, Attack& attack)
{ {
Fighter& fighter = sim.fighters[attack.owner]; Fighter& fighter = sim.fighters[attack.owner];
@@ -1759,14 +1767,14 @@ void Weapons::teleport_move(Sim& sim, Attack& attack)
attack.hitsLeft = 0; attack.hitsLeft = 0;
if (attack.time < attack.duration) if (attack.time < attack.duration)
{ {
if (!attack.isCopy) if (attack.hand == 0)
sim.cues->burst_spawn(WARP_BURST, fighter.center_get(), 0, attack.owner); sim.cues->burst_spawn(WARP_BURST, fighter.center_get(), 0, attack.owner);
return; return;
} }
attack.isDead = true; attack.isDead = true;
Vec2 spot = attack.position; 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); 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())); spot = teleport_spot(sim, fighter, attack.position, vec2_length(attack.position - fighter.center_get()));
@@ -1898,9 +1906,9 @@ std::vector<Path> Weapons::paths_get(Sim& sim, Fighter& fighter)
float reach = stats.range * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1); float reach = stats.range * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1);
for (int split = 0; split < splits; split++) 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); 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; return paths;
} }
@@ -1917,7 +1925,7 @@ std::vector<Path> Weapons::paths_get(Sim& sim, Fighter& fighter)
float reach = std::min(stats.range, TRAJECTORY_REACH); float reach = std::min(stats.range, TRAJECTORY_REACH);
for (int split = 0; split < splits; split++) 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); Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1);
float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim); float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim);
Vec2 velocity = isArrow ? aim * speed : 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)) if (&shot == &source || shot.isExplosion || !is_drifting(shot) || !is_in_cone(source, shot.position))
continue; continue;
const AttackDef& def = *shot.def(); 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; 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.ownerPrevious = shot.owner;
shot.owner = source.owner; shot.owner = source.owner;
shot.claimTime = shot.time; 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.isSecondary = false;
shot.struck = 0; shot.struck = 0;
shot.hitCooldown = 0; shot.hitCooldown = 0;
+21 -14
View File
@@ -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. // 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 HITS_ENDLESS = 1 << 30;
constexpr int WHIP_RAYS = 9; 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; class Fighter;
struct Dummy; struct Dummy;
@@ -431,14 +433,14 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isProjectile = true}, .isProjectile = true},
.levels = .levels =
{ {
{.normal = {.damage = 16, .lifetime = 0.3f, .lunge = 70, .scale = 0.88f, .speed = 60}, {.normal = {.damage = 16, .lifetime = 0.3f, .lunge = 70, .scale = 0.66f, .speed = 60},
.charged = {.damage = 34, .lifetime = 0.6f, .lunge = 140, .scale = 2.12f, .speed = 150}, .charged = {.damage = 34, .lifetime = 0.6f, .lunge = 140, .scale = 1.06f, .speed = 150},
.cooldown = 0.3f}, .cooldown = 0.3f},
{.normal = {.damage = 23, .lifetime = 0.3f, .lunge = 70, .scale = 1.08f, .speed = 60}, {.normal = {.damage = 23, .lifetime = 0.3f, .lunge = 70, .scale = 0.81f, .speed = 60},
.charged = {.damage = 45, .lifetime = 0.6f, .lunge = 140, .scale = 2.5f, .speed = 150}, .charged = {.damage = 45, .lifetime = 0.6f, .lunge = 140, .scale = 1.25f, .speed = 150},
.cooldown = 0.3f}, .cooldown = 0.3f},
{.normal = {.damage = 28, .lifetime = 0.3f, .lunge = 70, .scale = 1.27f, .speed = 60}, {.normal = {.damage = 28, .lifetime = 0.3f, .lunge = 70, .scale = 0.9525f, .speed = 60},
.charged = {.damage = 55, .lifetime = 0.6f, .lunge = 140, .scale = 2.88f, .speed = 150}, .charged = {.damage = 55, .lifetime = 0.6f, .lunge = 140, .scale = 1.44f, .speed = 150},
.cooldown = 0.3f}, .cooldown = 0.3f},
}, },
.bot = {.rangeMax = 48}, .bot = {.rangeMax = 48},
@@ -556,13 +558,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.isInstant = true}, .isInstant = true},
.levels = .levels =
{ {
{.normal = {.damage = 24, .lifetime = 0.38f, .speed = 950}, {.normal = {.damage = 12, .lifetime = 0.38f, .speed = 475},
.charged = {.area = 6, .damage = 46, .range = 125}, .charged = {.area = 6, .damage = 46, .range = 125},
.cooldown = 0.4f}, .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}, .charged = {.area = 7, .damage = 70, .range = 155},
.cooldown = 0.4f}, .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}, .charged = {.area = 8, .damage = 93, .range = 185},
.cooldown = 0.4f}, .cooldown = 0.4f},
}, },
@@ -606,13 +608,13 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = {
.levels = .levels =
{ {
{.normal = {.damage = 11, .lifetime = LIFETIME_INFINITE, .range = 33, .scale = 1, .speed = 518}, {.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}, .cooldown = 0.5f},
{.normal = {.damage = 15, .lifetime = LIFETIME_INFINITE, .range = 41, .scale = 1.5f, .speed = 644}, {.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}, .cooldown = 0.5f},
{.normal = {.damage = 19, .lifetime = LIFETIME_INFINITE, .range = 47, .scale = 2, .speed = 738}, {.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}, .cooldown = 0.5f},
}, },
.bot = {.rangeMax = 60}, .bot = {.rangeMax = 60},
@@ -1377,6 +1379,8 @@ struct Attack
// When an arming attack came to rest (see is_armed), never until it has. // When an arming attack came to rest (see is_armed), never until it has.
float restTime = LIFETIME_INFINITE; float restTime = LIFETIME_INFINITE;
float scale = 1; 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. // A crawling bolt's side its surface is on: 1 for its right (as it heads), -1 for its left.
float side = 0; float side = 0;
// A bouncing shot's slope under it, carried between ticks with whether it's on the ground (see // 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; Uint32 struck = 0;
int weapon = 0; int weapon = 0;
int key = -1; 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). // The hand it came from: the main hand (0) or a Dual Wield off-hand (1 up).
int hand = 0; int hand = 0;
AttackVariant variant = VARIANT_NORMAL; AttackVariant variant = VARIANT_NORMAL;
@@ -1499,10 +1506,10 @@ inline bool is_cone(const AttackDef& def)
return def.shape == SHAPE_CONE; 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) 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 // 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