Shared tomatoes, P1 sandbox spawns, and weapons left out

- Tomato splats are kept in the match, so everyone sees everyone's
  (others' arrive in replays, which play no effects), each splatting
  with juice and a sound once.
- The sandbox's first player (solo or hosting) spawns on the arena's
  P1 mark.
- A thrown flail ball or grapple stays out when switching weapons, and
  another weapon fires while a grapple hangs on. Mines, flail balls and
  grapples go once their slot no longer holds the weapon that put them
  out (mines fizzling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 19:02:15 -05:00
1 parent e88fed1f4c
commit e55596b276
10 files changed
+84 -79

No files matched your search

-32
View File
@@ -14,12 +14,6 @@ constexpr float FLASH_LIFE = 0.05f;
constexpr float FLASH_WIDTH = 6;
constexpr float RING_LIFE = 0.3f;
constexpr int RING_WIDTH = 2;
constexpr float SPLAT_CORE = 6;
constexpr float SPLAT_FADE = 0.6f;
constexpr float SPLAT_LIFE = 2;
constexpr float SPLAT_REACH = 10;
constexpr float SPLAT_SIZE_MAX = 3.5f;
constexpr float SPLAT_SIZE_MIN = 1.5f;
constexpr float AFTERIMAGE_LIFE = 0.12f;
constexpr float NUMBER_GAP = 8;
constexpr float POPUP_FADE = 0.4f;
@@ -133,19 +127,6 @@ void Effects::ring_spawn(Vec2 position, float radius, Color color)
effect_add(rings, Ring{color, position, RING_LIFE, radius});
}
// A big blob where it landed, with smaller ones flung round it.
void Effects::splat_spawn(Vec2 position, Color color)
{
Splat splat = {.color = color, .position = position, .life = SPLAT_LIFE};
splat.sizes[0] = SPLAT_CORE;
for (int i = 1; i < SPLAT_BLOBS; i++)
{
splat.blobs[i] = vec2_polar(random_get(0, 2 * PI), random_get(SPLAT_CORE, SPLAT_REACH));
splat.sizes[i] = random_get(SPLAT_SIZE_MIN, SPLAT_SIZE_MAX);
}
effect_add(splats, splat);
}
void Effects::dps_record(int amount)
{
hits.push_back({ticks * TICK, amount});
@@ -187,10 +168,6 @@ void Effects::update()
ring.life -= TICK;
std::erase_if(rings, [](Ring& ring) { return ring.life <= 0; });
for (Splat& splat : splats)
splat.life -= TICK;
std::erase_if(splats, [](Splat& splat) { return splat.life <= 0; });
for (Afterimage& afterimage : afterimages)
afterimage.life -= TICK;
std::erase_if(afterimages, [](Afterimage& afterimage) { return afterimage.life <= 0; });
@@ -232,14 +209,6 @@ void Effects::draw(Game& game)
renderer.line_draw(flash.position - across, flash.position + across, width * FLASH_CROSS, flash.color);
}
// Each splat holds, then fades out over its last moments.
for (Splat& splat : splats)
{
Color color = color_fade(splat.color, std::min(1.0f, splat.life / SPLAT_FADE));
for (int i = 0; i < SPLAT_BLOBS; i++)
renderer.circle_draw(splat.position + splat.blobs[i], splat.sizes[i], color);
}
// Each ring eases out to its full radius as it fades.
for (Ring& ring : rings)
{
@@ -290,6 +259,5 @@ void Effects::clear()
particles.clear();
popups.clear();
rings.clear();
splats.clear();
shake = 0;
}
-14
View File
@@ -58,18 +58,6 @@ struct Ring
float radius = 0;
};
constexpr int SPLAT_BLOBS = 6;
// A tomato's splat: blobs of colour round where it landed, fading out.
struct Splat
{
Color color;
Vec2 position;
Vec2 blobs[SPLAT_BLOBS];
float sizes[SPLAT_BLOBS];
float life = 0;
};
// A fading copy of a sprite left behind by something moving fast.
struct Afterimage
{
@@ -85,7 +73,6 @@ public:
void afterimage_spawn(Pose pose);
void flash_spawn(Vec2 position, float angle, float size, Color color);
void ring_spawn(Vec2 position, float radius, Color color);
void splat_spawn(Vec2 position, Color color);
void afterimage_draw(Game& game);
void number_spawn(Vec2 position, int value, int target, Color color);
void dps_record(int amount);
@@ -104,7 +91,6 @@ private:
std::vector<Particle> particles;
std::vector<Popup> popups;
std::vector<Ring> rings;
std::vector<Splat> splats;
float shake = 0;
int ticks = 0;
};
+3 -5
View File
@@ -397,7 +397,7 @@ void Fighter::rope_update(Attack& hook)
// The controls' slot picks a weapon slot, wrapping around, and the level buttons level the weapon copy in it. The
// weapon switched away from becomes the off-hand weapon for Dual Wield (a copy of the same weapon counts when weapons
// stack), and a thrown flail ball is dropped.
// stack). A thrown flail ball or grapple out stays out.
void Fighter::slot_update(Sim& sim)
{
int previous = slot;
@@ -408,10 +408,7 @@ void Fighter::slot_update(Sim& sim)
if (slot != previous)
sim.cues->sound_play(SOUND_SELECT);
if (slot != previous && weaponLoadout[previous] >= 0 && weapon_key(previous) != weapon_key(slot))
{
slotSwitches[previous] = ++switches;
sim.weapons.attacks_release(*this, ATTACK_FLAIL);
}
int step = (int)is_pressed(CONTROL_LEVEL_UP) - (int)is_pressed(CONTROL_LEVEL_DOWN);
if (step != 0 && held >= 0 && sim.match.state == MATCH_PRACTICE)
@@ -605,7 +602,8 @@ void Fighter::charge_update(Sim& sim)
if (is_released(CONTROL_ATTACK))
sim.weapons.wisps_release(*this);
if (hook && (isLeftPressed || isRightPressed))
// A latched grapple lets go when clicked with the grapple in hand; another weapon fires as it hangs on.
if (hook && hook->key == weapon_key(slot) && (isLeftPressed || isRightPressed))
{
sim.weapons.attacks_release(*this, ATTACK_HOOK);
isFireBlocked = isLeftPressed;
+4 -1
View File
@@ -86,6 +86,8 @@ Match::Match()
void Match::update(Sim& sim)
{
timer -= TICK;
for (Tomato& tomato : tomatoes)
tomato.age += TICK;
// Offline, the menu's Play starts a game, and leaving the results goes back to the sandbox.
bool isPlay = !isOnline && isPlayRequested;
bool isSandbox = !isOnline && state == MATCH_OVER && isSandboxRequested;
@@ -429,7 +431,8 @@ void Match::tomatoes_update(Sim& sim)
if (i == winner || isGone[i] || tomatoCooldowns[i] > 0 || !isThrown)
continue;
tomatoCooldowns[i] = TOMATO_COOLDOWN;
sim.cues->tomato_splat(fighter.controls.cursor, i);
tomatoes[tomatoCount % TOMATO_MAX] = {.position = fighter.controls.cursor, .owner = i, .serial = tomatoCount};
tomatoCount++;
}
}
+16 -1
View File
@@ -175,6 +175,18 @@ struct Queue
bool isActive = false;
};
constexpr int TOMATO_MAX = 32;
// A tomato splatted after a round: where it landed, who threw it, how long ago, and its number among every one thrown
// in the match (which also keeps its look the same on every screen).
struct Tomato
{
Vec2 position;
float age = 0;
int owner = -1;
int serial = 0;
};
// Best-of-three deathmatch, or a single race, played by the rules. Practice is the sandbox with the dummy and the menu.
// Each round opens with a long countdown to ready up in (cut short once every fighter is ready), at the round's level
// and health. Countdown is how long it ran from: longer on rounds where fighters choose items. On the rounds items are
@@ -192,8 +204,11 @@ public:
float clock = 0;
float countdown = READY_TIME;
float finishTimes[MAX_FIGHTERS] = {};
// How long until each fighter can splat another tomato on the results.
// How long until each fighter can splat another tomato, and the latest splats, kept with the game so everyone sees
// everyone's.
float tomatoCooldowns[MAX_FIGHTERS] = {};
Tomato tomatoes[TOMATO_MAX];
int tomatoCount = 0;
float timer = 0;
int round = 0;
int winner = -1;
+44 -11
View File
@@ -69,6 +69,16 @@ constexpr float WHIP_INNER = 2;
constexpr float WHIP_JITTER = 0.5f;
constexpr float WHIP_OUTER = 5;
constexpr float WISP_FRAME_STAGGER = 0.3f;
constexpr int TOMATO_BLOBS = 6;
constexpr float TOMATO_BLOB_MAX = 3.5f;
constexpr float TOMATO_BLOB_MIN = 1.5f;
constexpr float TOMATO_CORE = 6;
constexpr float TOMATO_FADE = 0.6f;
// How old a tomato can be when this screen first sees it and still splat with juice and a sound.
constexpr float TOMATO_FRESH = 0.5f;
constexpr float TOMATO_LIFE = 2;
constexpr Uint32 TOMATO_LOOK_STEP = 2654435761u;
constexpr float TOMATO_REACH = 10;
constexpr Burst TOMATO_BURST = {.gravity = 400, .life = 0.5f, .size = 2, .speed = 140, .spread = 1.2f, .count = 12};
// A random offset for an empowered attack or fighter to shake by, wider the more its colour fills in, like a damage
@@ -391,17 +401,6 @@ void Scene::match_start()
game.menu.isOpen = false;
}
// A tomato thrown on the results: a splat in the thrower's colour where it landed, spraying juice.
void Scene::tomato_splat(Vec2 position, int owner)
{
if (isMuted)
return;
Color color = game.color_get(owner, true);
game.effects.splat_spawn(position, color);
game.effects.burst(TOMATO_BURST, position, -PI / 2, color);
game.audio.play(SOUND_SPLAT);
}
// Readying with empty slots dealt them: this screen's own fighters hear the items go in.
void Scene::deal_play(int fighter)
{
@@ -425,6 +424,39 @@ void Scene::update()
{
for (int i = 0; i < game.sim.fighterCount; i++)
animator_update(game.sim.fighters[i], game.sprites.fighters[i]);
// Each new tomato (this screen's, or someone else's once their click arrives) splats with juice and a sound, once.
Match& match = game.sim.match;
tomatoesSeen = std::min(tomatoesSeen, match.tomatoCount);
for (; tomatoesSeen < match.tomatoCount; tomatoesSeen++)
{
Tomato& tomato = match.tomatoes[tomatoesSeen % TOMATO_MAX];
if (tomato.serial != tomatoesSeen || tomato.age > TOMATO_FRESH)
continue;
game.effects.burst(TOMATO_BURST, tomato.position, -PI / 2, game.color_get(tomato.owner, true));
game.audio.play(SOUND_SPLAT);
}
}
// The match's tomatoes as splats in their throwers' colours: a big blob where each landed with smaller ones flung round
// it (the same on every screen), holding and then fading out.
void Scene::tomatoes_draw()
{
for (Tomato& tomato : game.sim.match.tomatoes)
{
if (tomato.owner < 0 || tomato.age >= TOMATO_LIFE)
continue;
Color color =
color_fade(game.color_get(tomato.owner, true), std::min(1.0f, (TOMATO_LIFE - tomato.age) / TOMATO_FADE));
game.renderer.circle_draw(tomato.position, TOMATO_CORE, color);
Rng look = {.state = (Uint32)tomato.serial * TOMATO_LOOK_STEP + 1};
for (int i = 1; i < TOMATO_BLOBS; i++)
{
Vec2 offset = vec2_polar(look.random_get(0, 2 * PI), look.random_get(TOMATO_CORE, TOMATO_REACH));
game.renderer.circle_draw(tomato.position + offset, look.random_get(TOMATO_BLOB_MIN, TOMATO_BLOB_MAX),
color);
}
}
}
// The game between its last two ticks: the world, the bots' map (when debugging), afterimages, blasts under the
@@ -438,6 +470,7 @@ void Scene::draw()
for (int i = 0; i < game.sim.fighterCount; i++)
fighter_draw(game.sim.fighters[i]);
attacks_draw();
tomatoes_draw();
if (game.settings.isCollisionShown)
collision_draw();
}
+3 -1
View File
@@ -36,14 +36,16 @@ public:
void level_step(bool isUp) override;
void match_start() override;
void deal_play(int fighter) override;
void tomato_splat(Vec2 position, int owner) override;
void sandbox_return() override;
private:
Game& game;
int chargeVoices[MAX_FIGHTERS];
// How many of the match's tomatoes have splatted on this screen.
int tomatoesSeen = 0;
void world_draw();
void tomatoes_draw();
void bot_draw();
void fighter_draw(Fighter& fighter);
const char* animation_get(Fighter& fighter);
-2
View File
@@ -41,8 +41,6 @@ public:
{}
virtual void deal_play(int fighter)
{}
virtual void tomato_splat(Vec2 position, int owner)
{}
virtual void sandbox_return()
{}
};
+11
View File
@@ -908,6 +908,17 @@ void Weapons::attack_move(Sim& sim, Attack& attack)
float progress = attack.time / attack.duration;
float range = attack.stats.range;
// 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.
bool isLeftOut = def.kind == ATTACK_MINE || def.kind == ATTACK_FLAIL || def.kind == ATTACK_HOOK;
if (isLeftOut && !attack.isExplosion && fighter.weaponLoadout[attack.key] != attack.weapon)
{
attack.isDead = true;
if (def.kind == ATTACK_MINE)
sim.cues->attack_burst_spawn(SMOKE_BURST, attack.position, 0, attack);
return;
}
switch (def.kind)
{
case ATTACK_SWING:
+3 -12
View File
@@ -211,9 +211,9 @@ static bool is_solid(Tile tile)
return tile == TILE_SOLID || tile == TILE_BLOCK;
}
// The arena with the practice dummy standing on the first surface below the middle of the map, and the player
// spawning right underneath it, on the next surface down. Its marked spawns are joined by more spread over its ground,
// for a lobby's sandbox full of players.
// The arena with the practice dummy standing on the first surface below the middle of the map. Fighters spawn on the
// arena's marked spawns (the first player, solo or hosting, on 1), joined by more spread over its ground for a lobby's
// sandbox full of players.
void World::sandbox_load()
{
arena_load();
@@ -225,15 +225,6 @@ void World::sandbox_load()
float standY = standRow * TILE_SIZE;
float centerX = width * TILE_SIZE / 2.0f;
dummies.push_back({.box = {centerX - DUMMY_WIDTH / 2, standY - DUMMY_HEIGHT, DUMMY_WIDTH, DUMMY_HEIGHT}});
int groundRow = standRow;
while (groundRow < height && tile_get(column, groundRow) != TILE_EMPTY)
groundRow++;
while (groundRow < height && tile_get(column, groundRow) == TILE_EMPTY)
groundRow++;
float groundY = groundRow * TILE_SIZE;
Ramp* ramp = ramp_find({centerX, groundY + TILE_SIZE / 2.0f});
spawns[0] = {centerX, ramp ? ramp->y_at(centerX) : groundY};
spawns_spread(SPAWN_MARKS);
}