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