Files
smack-a-brother/src/scene.cpp
T
shweetandClaude Opus 5.5 9a41474069 Tomatoes, match tabs, shared pings, and a round of fixes
- On the results, everyone but the winner splats tomatoes where they
  click (bots aim at the winner), with a new splat sound.
- During a match the menu has the item tabs (choosing when the
  countdown allows, and otherwise levelling the sandbox's levels for
  when you're back), Play (the game's rules) and Settings.
- Pings are shared through the server (protocol 4), so other players'
  connection bars show over their heads and on their windows.
- PvP comes into lobbies with your profile and is saved.
- The choice menu opens by itself however the countdown began.
- Knocked-out fighters keep their buttons current, so Ready on the
  results toggles once per press.
- The weapon wheel wraps both ways. Readying with empty slots plays
  the equip sound for your own fighter.
- Shock Absorber: up to 50% less weapon movement and 33% less
  knockback. Only the catalog previews the next level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 18:11:06 -05:00

1006 lines
41 KiB
C++

#include "scene.h"
#include "game.h"
#include <SDL3/SDL_timer.h>
#include <algorithm>
#include <cmath>
#include <string>
constexpr int BLINK_MILLISECONDS = 80;
constexpr float TRAJECTORY_ALPHA = 0.12f;
constexpr float TRAJECTORY_TAPER = 0.25f;
constexpr float LINK_WIDTH = 1;
constexpr float SURFACE_WIDTH = 2;
constexpr float BLOCK_WARN = 1;
constexpr Vec2 CORNER_DIRECTIONS[] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}};
constexpr int CORNER_SIZE = 2;
constexpr float RAMP_OUTLINE = 2;
constexpr float CHECKPOINT_ALPHA = 0.3f;
constexpr float CHECKPOINT_HEIGHT = 24;
// Animations drawn facing their own way, picked by the way the fighter moves (right, then left). The rest are
// mirrored to face left.
constexpr const char* CRAWL_ANIMATIONS[] = {"Crawl Right", "Crawl Left"};
constexpr float POUND_RING_ALPHA = 0.25f;
constexpr const char* WALK_ANIMATIONS[] = {"Walk Right", "Walk Left"};
constexpr Burst AURA_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.3f, .count = 1};
constexpr float BEAM_INNER = 4;
constexpr float BEAM_OUTER = 10;
constexpr float BOLT_JITTER = 2;
constexpr float BOLT_SEGMENT = 8;
constexpr float BOLT_WIDTH = 1;
constexpr float BOOMERANG_SPIN = 20;
constexpr float CHAIN_SPACING = 8;
// How hard a charging weapon shakes against an attack filled in as far.
constexpr float CHARGE_SHAKE = 0.5f;
constexpr float COLLISION_ALPHA = 0.25f;
constexpr float EXPLOSION_FLASH = 0.05f;
constexpr float EXPLOSION_GROW = 4;
constexpr float EXPLOSION_OUTLINE = 2;
constexpr float FALLOFF_CURVE = 1.5f;
constexpr float FALLOFF_DIM = 0.15f;
constexpr float FALLOFF_STEP = 6;
constexpr float FILL_JITTER = 2;
constexpr float FILL_PARTICLES = 60;
constexpr float FLAME_FADE = 3;
constexpr float FLAME_GROW = 0.8f;
constexpr float FLAME_SHRINK = 0.6f;
constexpr float FLAME_STAGGER = 1.12f;
constexpr float GRAPPLE_HANDLE = 7;
constexpr float HELD_ICON_SCALE = 0.6f;
constexpr float KICK_DECAY = 0.82f;
constexpr float KICK_SWEEP = 0.1f;
constexpr float MINE_BLINK = 0.6f;
constexpr float MINE_BLINK_ON = 0.1f;
constexpr float PIP_GAP = 3;
constexpr float PIP_SIZE = 2;
constexpr float PIP_SPIN = 8;
constexpr float RELOAD_DROP = 5;
constexpr float RELOAD_PUMP = 4;
constexpr float RELOAD_START = 0.2f;
constexpr float ROPE_INNER = 1;
constexpr float ROPE_OUTER = 3;
constexpr float TELEPORT_CORE = 0.4f;
constexpr float TELEPORT_RING_ALPHA = 0.35f;
constexpr float WHIP_INNER = 2;
constexpr float WHIP_JITTER = 0.5f;
constexpr float WHIP_OUTER = 5;
constexpr float WISP_FRAME_STAGGER = 0.3f;
constexpr Burst TOMATO_BURST = {.gravity = 400, .life = 0.5f, .size = 2, .speed = 140, .spread = 1.2f, .count = 12};
// A random offset for an empowered attack or fighter to shake by, wider the more its colour fills in, like a damage
// number.
static Vec2 jitter_get(float fill)
{
float reach = FILL_JITTER * fill;
return {random_get(-reach, reach), random_get(-reach, reach)};
}
// Sheds particles off the edge of an empowered shape, more the more its colour fills in. They take the shape's colour
// and fill in just as far.
static void aura_spawn(Game& game, Capsule shape, float fill, Color color)
{
Burst burst = AURA_BURST;
burst.fill = fill;
int count = FILL_PARTICLES * fill * TICK + random_get(0, 1);
for (int i = 0; i < count; i++)
{
float angle = random_get(-PI, PI);
Vec2 point = vec2_lerp(shape.from, shape.to, random_get(0, 1)) + vec2_polar(angle, shape.radius);
game.effects.burst(burst, point, angle, color);
}
}
// A colour decayed to match a weakened strength: drained toward grey and darkened, faster than the damage drops so
// weakened shots read clearly.
static Color color_dim(Color color, float strength)
{
strength = math_pow(std::min(1.0f, strength), FALLOFF_CURVE);
float grey = (color.r + color.g + color.b) / 3;
Color washed = {math_lerp(grey, color.r, strength), math_lerp(grey, color.g, strength),
math_lerp(grey, color.b, strength), color.a};
return color_shade(washed, FALLOFF_DIM + (1 - FALLOFF_DIM) * strength);
}
// The owner's colour (darker for secondary attacks), dimming as a shot's damage falls off.
static Color owner_color_get(Game& game, Attack& attack)
{
return color_shade(game.color_get(attack.owner), attack.isSecondary ? SECONDARY_SHADE : 1);
}
static Color tint_get(Game& game, Attack& attack)
{
return color_dim(owner_color_get(game, attack), strength_get(attack, attack.tip));
}
// A path drawn as an outlined line: an outer width around a black core of the inner width, kinked by jitter.
struct Stroke
{
std::vector<Vec2> points;
float outer = 0;
float inner = 0;
float jitter = 0;
};
// A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Beams
// start as far along as they've travelled. Jitter kinks it at every piece, moving each frame so it crackles.
static void strength_line_draw(Game& game, Attack& attack, Stroke stroke)
{
Color color = owner_color_get(game, attack);
float base = attack.bonus * (1 + attack.ricochets * RICOCHET_BONUS);
Vec2 shake = jitter_get(fill_get(base));
std::vector<Vec2> points;
std::vector<float> distances;
float distance = attack.def()->kind == ATTACK_BEAM ? attack.travelled : 0;
for (size_t i = 0; i + 1 < stroke.points.size(); i++)
{
Vec2 from = stroke.points[i];
Vec2 delta = stroke.points[i + 1] - from;
Vec2 normal = vec2_normalize({-delta.y, delta.x});
float length = vec2_length(delta);
int steps = length / FALLOFF_STEP + 1;
for (int step = 0; step < steps; step++)
{
bool isEnd = i == 0 && step == 0;
float nudge = isEnd ? 0 : random_get(-stroke.jitter, stroke.jitter);
points.push_back(from + delta * ((float)step / steps) + normal * nudge + shake);
distances.push_back(distance + length * (step + 0.5f) / steps);
}
distance += length;
}
if (stroke.points.empty())
return;
points.push_back(stroke.points.back() + shake);
for (int layer = 0; layer < 2; layer++)
{
for (size_t i = 0; i + 1 < points.size(); i++)
{
float strength = base * falloff_at(distances[i]);
if (layer == 0)
game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength));
else
game.renderer.line_draw(points[i], points[i + 1], stroke.inner, color_shade(color, fill_get(strength)));
}
}
}
// A kinked line from one end of the capsule to the other, its radius as the width. Each kink is nudged sideways
// by up to jitter.
static void bolt_draw(Renderer& renderer, Capsule bolt, float jitter, Color color)
{
Vec2 delta = bolt.to - bolt.from;
Vec2 normal = vec2_normalize({-delta.y, delta.x});
int kinks = vec2_length(delta) / BOLT_SEGMENT + 1;
Vec2 previous = bolt.from;
for (int k = 1; k <= kinks; k++)
{
float nudge = k == kinks ? 0 : random_get(-jitter, jitter);
Vec2 point = bolt.from + delta * ((float)k / kinks) + normal * nudge;
renderer.line_draw(previous, point, bolt.radius, color);
previous = point;
}
}
// Fills every pixel inside any of the capsules, a row of runs at a time, so where they overlap stays even.
static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color color)
{
Rect bounds = {INFINITY, INFINITY, -INFINITY, -INFINITY};
for (int i = 0; i < count; i++)
{
Capsule& shape = shapes[i];
bounds.x = std::min({bounds.x, shape.from.x - shape.radius, shape.to.x - shape.radius});
bounds.y = std::min({bounds.y, shape.from.y - shape.radius, shape.to.y - shape.radius});
bounds.w = std::max({bounds.w, shape.from.x + shape.radius, shape.to.x + shape.radius});
bounds.h = std::max({bounds.h, shape.from.y + shape.radius, shape.to.y + shape.radius});
}
auto isInside = [&](Vec2 point) {
for (int i = 0; i < count; i++)
{
Vec2 line = shapes[i].to - shapes[i].from;
float length = vec2_dot(line, line);
float t = length > 0 ? std::clamp(vec2_dot(point - shapes[i].from, line) / length, 0.0f, 1.0f) : 0;
if (vec2_length(point - (shapes[i].from + line * t)) <= shapes[i].radius)
return true;
}
return false;
};
for (float y = std::floor(bounds.y); y <= bounds.h; y++)
{
float start = -INFINITY;
for (float x = std::floor(bounds.x); x <= bounds.w + 1; x++)
{
bool isIn = x <= bounds.w && isInside({x + 0.5f, y + 0.5f});
if (isIn && start == -INFINITY)
start = x;
if (!isIn && start != -INFINITY)
{
renderer.rect_draw({start, y, x - start, 1}, color);
start = -INFINITY;
}
}
}
}
// The held weapon's turn away from the aim: a quick eased sweep from the kick to the follow-through, which then
// settles back to the aim.
static float kick_get(Fighter& fighter)
{
if (fighter.kickTime < KICK_SWEEP)
{
float t = fighter.kickTime / KICK_SWEEP;
return math_lerp(fighter.kickFrom, fighter.kickTo, t * (2 - t));
}
return fighter.kickTo * math_pow(KICK_DECAY, (fighter.kickTime - KICK_SWEEP) / TICK);
}
// How much of a weapon's cooldown is left, from 1 just fired to 0 ready.
static float cooldown_left_get(Fighter& fighter, int key)
{
float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1);
return total > 0 ? std::clamp(fighter.cooldowns[key] / total, 0.0f, 1.0f) : 0;
}
// A reloading weapon's pose through its cooldown: once the kick has played out it tips up to stand vertical, dips
// down and back like a pump, then swings back to the aim just as it's ready.
static void reload_pose(Fighter& fighter, int key, Draw& draw)
{
if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || fighter.cooldowns[key] <= 0)
return;
float t = std::clamp((1 - cooldown_left_get(fighter, key) - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f);
float weight = math_sin(PI * t);
draw.angle = angle_lerp(draw.angle, -PI / 2, weight);
draw.position.y += RELOAD_DROP * math_pow(weight, RELOAD_PUMP);
}
// How far a fighter's weapons (and crosshair) fill in with its colour: with the charge as it builds, or the speed bonus
// if that's more, then once the charge is ready blinking between full and the speed bonus alone.
float Scene::charge_fill_get(Fighter& fighter)
{
float buff = game.sim.weapons.buff_get(fighter);
if (fighter.charge >= 1)
return is_flashing(fighter) ? 1.0f : buff;
return std::max(buff, fighter.charge);
}
// How far a fighter's weapon (by key, or -1 for none) and crosshair shake this frame: harder the further its charge has
// got, and from fully charged as the weapon is used, easing to still as its cooldown runs out.
Vec2 Scene::weapon_shake_get(Fighter& fighter, int key)
{
float left = key < 0 ? 0 : cooldown_left_get(fighter, key);
return jitter_get(std::max(fighter.charge, left * left) * CHARGE_SHAKE);
}
static bool is_pair(const char* const (&pair)[2], const char* animation)
{
return animation == pair[0] || animation == pair[1];
}
Scene::Scene(Game& game) : game(game)
{
std::fill(std::begin(chargeVoices), std::end(chargeVoices), -1);
}
void Scene::sound_play(SoundId sound)
{
if (!isMuted)
game.audio.play(sound);
}
void Scene::charge_play(Fighter& fighter)
{
if (!isMuted)
chargeVoices[fighter.index] = game.audio.play(SOUND_CHARGE);
}
void Scene::charge_stop(Fighter& fighter)
{
game.audio.stop(chargeVoices[fighter.index]);
chargeVoices[fighter.index] = -1;
}
void Scene::burst_spawn(Burst burst, Vec2 position, float angle, int owner)
{
if (!isMuted)
game.effects.burst(burst, position, angle, game.color_get(owner));
}
void Scene::attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack)
{
if (!isMuted)
game.effects.burst(burst, position, angle, tint_get(game, attack));
}
void Scene::shake_add(float amount)
{
if (!isMuted)
game.effects.shake_add(amount);
}
void Scene::flash_spawn(Vec2 position, float angle, float size, int owner)
{
if (!isMuted)
game.effects.flash_spawn(position, angle, size, game.color_get(owner));
}
void Scene::ring_spawn(Vec2 position, float radius, int owner)
{
if (!isMuted)
game.effects.ring_spawn(position, radius, game.color_get(owner));
}
void Scene::fighter_afterimage_spawn(Fighter& fighter)
{
if (!isMuted)
game.effects.afterimage_spawn(fighter_pose_get(fighter));
}
void Scene::attack_afterimage_spawn(Attack& attack)
{
if (!isMuted)
game.effects.afterimage_spawn(attack_pose(attack));
}
void Scene::fighter_aura_spawn(Fighter& fighter)
{
if (isMuted)
return;
Rect box = fighter.body.box;
float radius = box.w / 2;
aura_spawn(game, {{box.x + radius, box.y + radius}, {box.x + radius, box.y + box.h - radius}, radius},
game.sim.weapons.buff_get(fighter), game.color_get(fighter.index));
}
void Scene::attack_aura_spawn(Attack& attack)
{
if (!isMuted)
aura_spawn(game, {attack.position, attack.tip, attack.radius}, fill_get(strength_get(attack, attack.tip)),
tint_get(game, attack));
}
void Scene::number_spawn(Damage& damage)
{
if (isMuted)
return;
game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner));
game.effects.dps_record(damage.amount);
}
// A level going up or down chimes, and outside the menu pops up over this screen's fighter.
void Scene::level_step(bool isUp)
{
if (isMuted)
return;
game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN);
if (game.menu.isOpen)
return;
Fighter& fighter = game.local_get();
Vec2 head = {fighter.center_get().x, fighter.body.box.y};
game.effects.popup_spawn(head, isUp ? "Level Up" : "Level Down", isUp ? game.ui.tint : game.ui.shade);
}
// The menu closes for whoever plays a starting match on this screen (online, a queued game starting doesn't close a
// bystander's).
void Scene::match_start()
{
if (game.local_count() > 0)
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)
{
if (!isMuted && game.is_local(fighter))
game.audio.play(SOUND_EQUIP);
}
void Scene::sandbox_return()
{
game.menu.isOpen = false;
}
// A fully charged fighter's weapons blink, by the clock rather than the game.
bool Scene::is_flashing(Fighter& fighter)
{
return fighter.charge >= 1 && SDL_GetTicks() / BLINK_MILLISECONDS % 2;
}
// Each tick, every fighter's animator plays whatever its fighter is doing.
void Scene::update()
{
for (int i = 0; i < game.sim.fighterCount; i++)
animator_update(game.sim.fighters[i], game.sprites.fighters[i]);
}
// The game between its last two ticks: the world, the bots' map (when debugging), afterimages, blasts under the
// fighters, the fighters, their attacks, and the hit shapes when they're shown.
void Scene::draw()
{
world_draw();
bot_draw();
game.effects.afterimage_draw(game);
blasts_draw();
for (int i = 0; i < game.sim.fighterCount; i++)
fighter_draw(game.sim.fighters[i]);
attacks_draw();
if (game.settings.isCollisionShown)
collision_draw();
}
// A very faint line along each path the held weapon's attack would take if fired now, fading out over the last quarter
// of its length (so a path cut short by a wall fades too), ringing the blast it would make where it ends.
void Scene::paths_draw(Fighter& fighter)
{
Color base = game.color_get(fighter.index);
for (Path& path : game.sim.weapons.paths_get(game.sim, fighter))
{
int last = path.steps.size() - 1;
for (int i = 0; i < last; i++)
{
float fade = std::clamp((last - i) / (last * TRAJECTORY_TAPER), 0.0f, 1.0f);
game.renderer.line_draw(path.steps[i], path.steps[i + 1], 1, color_fade(base, TRAJECTORY_ALPHA * fade));
}
if (path.area > 0)
game.renderer.ring_draw(path.steps.back(), path.area, color_fade(base, BLAST_RING_ALPHA));
}
}
void Scene::world_draw()
{
Renderer& renderer = game.renderer;
World& world = game.sim.world;
Anm2& tileArt = game.sprites.tiles;
int startX = std::max(0, (int)std::floor(renderer.camera.x / TILE_SIZE));
int startY = std::max(0, (int)std::floor(renderer.camera.y / TILE_SIZE));
int endX = std::min(world.width - 1, (int)std::floor((renderer.camera.x + CANVAS_WIDTH) / TILE_SIZE));
int endY = std::min(world.height - 1, (int)std::floor((renderer.camera.y + CANVAS_HEIGHT) / TILE_SIZE));
for (int y = startY; y <= endY; y++)
{
for (int x = startX; x <= endX; x++)
{
auto isOpen = [&](float dx, float dy) {
Tile neighbour = world.tile_get(x + dx, y + dy);
return neighbour != TILE_SOLID && neighbour != TILE_SLOPE;
};
auto isEmpty = [&](int dx) {
return world.tile_get(x + dx, y) == TILE_EMPTY;
};
Tile tile = world.tile_get(x, y);
Vec2 position = {(float)x * TILE_SIZE, (float)y * TILE_SIZE};
if (tile == TILE_SOLID)
{
int mask = isOpen(0, -1) | isOpen(1, 0) << 1 | isOpen(0, 1) << 2 | isOpen(-1, 0) << 3;
tileArt.draw(renderer, "Wall", mask, {.position = position});
for (Vec2 corner : CORNER_DIRECTIONS)
{
if (isOpen(corner.x, 0) || isOpen(0, corner.y) || !isOpen(corner.x, corner.y))
continue;
float cornerX = corner.x < 0 ? position.x : position.x + TILE_SIZE - CORNER_SIZE;
float cornerY = corner.y < 0 ? position.y : position.y + TILE_SIZE - CORNER_SIZE;
renderer.rect_draw({cornerX, cornerY, CORNER_SIZE, CORNER_SIZE}, WHITE);
}
}
else if (tile == TILE_PLATFORM)
{
int mask = isEmpty(1) | isEmpty(-1) << 1;
tileArt.draw(renderer, "Platform", mask, {.position = position});
}
else if (tile == TILE_PIT || tile == TILE_FINISH)
tileArt.draw(renderer, tile == TILE_PIT ? "Pit" : "Finish", 0, {.position = position});
}
}
// Ramps: an outline just inside the slope and its flat top, and a wall down the high end where it stands free.
for (Ramp& ramp : world.ramps)
{
if (ramp.right < renderer.camera.x || ramp.left > renderer.camera.x + CANVAS_WIDTH)
continue;
float edge = ramp.anchor + (ramp.top - ramp.base) / ramp.slope;
Vec2 low = {ramp.anchor, ramp.base};
Vec2 high = {edge, ramp.top};
Vec2 end = {ramp.slope < 0 ? ramp.right : ramp.left, ramp.top};
Vec2 inset = vec2_normalize({ramp.slope, -1}) * (-RAMP_OUTLINE / 2);
renderer.line_draw(low + inset, high + inset, RAMP_OUTLINE, WHITE);
renderer.line_draw({high.x, high.y + RAMP_OUTLINE / 2}, {end.x, end.y + RAMP_OUTLINE / 2}, RAMP_OUTLINE, WHITE);
float side = ramp.slope < 0 ? 1 : -1;
Vec2 beyond = {end.x + side * TILE_SIZE / 2, ramp.top + TILE_SIZE / 2};
if (world.tile_at(beyond) != TILE_SOLID)
renderer.line_draw({end.x - side * RAMP_OUTLINE / 2, ramp.top},
{end.x - side * RAMP_OUTLINE / 2, ramp.base}, RAMP_OUTLINE, WHITE);
}
// Blocks crack as they lose health, and a broken one shows a faint outline just before it grows back.
for (Dummy& dummy : world.dummies)
{
if (dummy.tile < 0)
continue;
Vec2 position = {dummy.box.x, dummy.box.y};
if (world.tiles[dummy.tile] == TILE_BLOCK)
{
int stage = std::min(BLOCK_STAGES - 1, (int)((1 - dummy.health / BLOCK_HEALTH) * BLOCK_STAGES));
tileArt.draw(renderer, "Block", stage, {.position = position, .flash = dummy.flash});
}
else if (dummy.regrow < BLOCK_WARN)
tileArt.draw(renderer, "Block", 0,
{.color = color_fade(GRAY, 1 - dummy.regrow / BLOCK_WARN), .position = position});
}
for (Dummy& dummy : world.dummies)
{
if (dummy.tile >= 0)
continue;
Vec2 base = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h};
game.sprites.dummy.draw(renderer, "Idle", 0, {.position = base, .angle = dummy.wobble, .flash = dummy.flash});
}
}
// Debug view: bot.surfaces as lines, bot.links as thin lines from takeoff to landing, and each bot's remaining path in
// white.
void Scene::bot_draw()
{
Bot& bot = game.sim.bot;
if (!bot.isDrawn)
return;
Renderer& renderer = game.renderer;
for (Surface& surface : bot.surfaces)
renderer.line_draw({surface.left, surface.y_at(surface.left)}, {surface.right, surface.y_at(surface.right)},
SURFACE_WIDTH, GRAY);
for (Link& link : bot.links)
renderer.line_draw({link.takeoff, bot.surfaces[link.from].y_at(link.takeoff)},
{link.landing, bot.surfaces[link.to].y_at(link.landing)}, LINK_WIDTH,
color_fade(GRAY, GOAL_SPAN));
for (int i = 0; i < game.sim.fighterCount; i++)
{
BotState& state = bot.states[i];
if (!game.sim.fighters[i].isBot)
continue;
for (int step = state.pathStep; step < state.pathLength; step++)
{
Link& link = bot.links[state.path[step]];
renderer.line_draw({link.takeoff, bot.surfaces[link.from].y_at(link.takeoff)},
{link.landing, bot.surfaces[link.to].y_at(link.landing)}, SURFACE_WIDTH, WHITE);
}
renderer.circle_draw(state.goal, SURFACE_WIDTH * 2, WHITE);
}
}
// Draws the fighter between its weapons, all shaking and filling in with the bonus its attacks would get right now.
// Behind it, in a race, a glow rising from the ground it would respawn on (its own only). Over it, the reach of an Iron
// Weights slam about to land. The local fighter also sees where its weapon would fire.
void Scene::fighter_draw(Fighter& fighter)
{
if (!fighter.isAlive)
return;
Renderer& renderer = game.renderer;
Color color = game.color_get(fighter.index);
bool isLocal = fighter.index == game.local && game.devices[fighter.index] != DEVICE_NONE;
if (isLocal && game.sim.match.is_racing())
for (int row = 0; row < CHECKPOINT_HEIGHT; row++)
renderer.rect_draw(
{std::round(fighter.safe.x - BOX_WIDTH / 2), std::round(fighter.safe.y) - row - 1, BOX_WIDTH, 1},
color_fade(color, CHECKPOINT_ALPHA * (1 - row / CHECKPOINT_HEIGHT)));
float fill = game.sim.weapons.buff_get(fighter);
Vec2 camera = renderer.camera;
renderer.camera = camera + jitter_get(fill);
Pose pose = fighter_pose_get(fighter);
pose.placement.flash = std::max(pose.placement.flash, fill);
offhand_draw(fighter);
pose.draw(renderer);
held_draw(fighter);
renderer.camera = camera;
float pound = fighter.pound_area_get();
if (pound > 0)
renderer.ring_draw({fighter.center_get().x, fighter.body.box.y + fighter.body.box.h}, pound,
color_fade(color, POUND_RING_ALPHA));
if (isLocal && !game.menu.isOpen && game.settings.isAimShown)
paths_draw(fighter);
}
// The animation for what the fighter is doing: tumbling when launched, kicking off or sliding down a wall, rolling,
// crawling, crouching, jumping or falling, walking, or standing.
const char* Scene::animation_get(Fighter& fighter)
{
if (fighter.isLaunched)
return "Hurt";
if (fighter.kickPose > 0)
return "Kick";
if (fighter.slideWall != 0)
return "Slide";
if (fighter.isSliding)
return "Roll";
if (fighter.is_crawling())
return CRAWL_ANIMATIONS[fighter.body.velocity.x < 0];
if (fighter.isCrouching)
return "Crouch";
if (!fighter.body.isGrounded)
return fighter.body.velocity.y < 0 ? "Jump" : "Fall";
if (std::abs(fighter.body.velocity.x) > STEP_SPEED)
return WALK_ANIMATIONS[fighter.body.velocity.x < 0];
return "Idle";
}
// Plays the fighter's animation for this tick. Walking and crawling cycle one frame per stride of ground covered, the
// rest play in time. Turning round mid-stride carries on the cycle in the other direction's animation.
void Scene::animator_update(Fighter& fighter, Animator& animator)
{
const char* animation = animation_get(fighter);
bool isWalking = is_pair(WALK_ANIMATIONS, animation);
bool isCrawling = is_pair(CRAWL_ANIMATIONS, animation);
bool isTurning = isWalking && is_pair(WALK_ANIMATIONS, animator.animation) ||
isCrawling && is_pair(CRAWL_ANIMATIONS, animator.animation);
float stride = isWalking ? WALK_STRIDE : isCrawling ? CRAWL_STRIDE : 0;
float frame = animator.frame;
animator.play(animation);
if (isTurning)
animator.frame = frame;
animator.update(stride > 0 ? std::abs(fighter.body.velocity.x) * TICK / stride : TICK * animator.art->fps);
}
// The fighter's animation placed where it is right now, in its colour: feet on the ground facing its way (walking and
// crawling face the way they move), facing away from a wall it kicks off or slides down, spinning about its middle when
// launched, and rolling as a ball resting on the ground along the surface's perpendicular. Shared with afterimages.
Pose Scene::fighter_pose_get(Fighter& fighter)
{
Animator& animator = game.sprites.fighters[fighter.index];
bool isDirected = is_pair(WALK_ANIMATIONS, animator.animation) || is_pair(CRAWL_ANIMATIONS, animator.animation);
float side = fighter.is_left() && !isDirected ? -1 : 1;
Draw draw = {.color = game.color_get(fighter.index),
.position = {fighter.center_get().x, fighter.body.box.y + fighter.body.box.h},
.scale = {side * fighter.scale, fighter.scale},
.flash = fighter.flash};
if (fighter.isLaunched)
{
draw.position = fighter.center_get();
draw.angle = fighter.tumble;
}
else if (fighter.kickPose > 0)
draw.scale.x = (fighter.body.velocity.x < 0 ? -1 : 1) * fighter.scale;
else if (fighter.slideWall != 0)
draw.scale.x = -fighter.slideWall * fighter.scale;
else if (fighter.isSliding)
{
Vec2 up = fighter.body.isGrounded ? vec2_normalize({fighter.body.slope, -1}) : Vec2{0, -1};
draw.position = draw.position + up * (ROLL_RADIUS * fighter.scale);
draw.angle = fighter.roll;
}
return animator.pose_get(draw);
}
void Scene::attacks_draw()
{
Renderer& renderer = game.renderer;
for (Attack& attack : game.sim.weapons.attacks)
{
Fighter& fighter = game.sim.fighters[attack.owner];
Color tint = tint_get(game, attack);
AttackKind kind = attack.def()->kind;
if (kind == ATTACK_HOOK)
{
// The rope runs from the end of the grapple's base in the hand.
Vec2 hand = fighter.hand_get(attack.hand);
Vec2 base = hand + vec2_polar(vec2_angle(attack.position - hand), GRAPPLE_HANDLE);
strength_line_draw(game, attack, {{base, attack.position}, ROPE_OUTER, ROPE_INNER});
}
if (kind == ATTACK_SPIN || kind == ATTACK_FLAIL)
{
Vec2 hand = fighter.hand_get();
Vec2 delta = attack.position - hand;
int links = vec2_length(delta) / CHAIN_SPACING;
for (int i = 1; i < links; i++)
game.sprites.effects.draw(renderer, "Chain", 0,
{.color = tint,
.position = hand + delta * ((float)i / links),
.flash = fill_get(strength_get(attack, attack.tip))});
}
float progress = attack.time / attack.duration;
if (kind == ATTACK_BEAM)
{
float width = (1 - progress) * attack.scale;
strength_line_draw(game, attack, {{attack.position, attack.tip}, BEAM_OUTER * width, BEAM_INNER * width});
}
else if (kind == ATTACK_WHIP)
{
// A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite
// rides the end of the whip, pointing the way it runs.
Vec2 shake = jitter_get(fill_get(strength_get(attack, attack.tip)));
float fade = attack.isCharged ? progress : std::max(0.0f, (progress - WHIP_UNFURL) / (1 - WHIP_UNFURL));
float width = (1 - fade) * attack.scale;
std::vector<Vec2> points =
attack.isCharged ? std::vector<Vec2>{attack.position, attack.tip} : lash_get(attack);
Vec2 end = points.back();
Vec2 before = points[points.size() - 2];
strength_line_draw(game, attack,
{points, WHIP_OUTER * width, WHIP_INNER * width, attack.isCharged ? 0 : WHIP_JITTER});
game.sprites.effects.draw(renderer, "Whip Tip", 0,
{.color = color_fade(tint, 1 - fade),
.position = end + shake,
.scale = {attack.scale, attack.scale},
.angle = vec2_angle(end - before)});
}
else if (kind != ATTACK_EXPLOSION)
attack_pose(attack).draw(renderer);
// A faint ring shows how far anything that explodes would reach, and pips in the shot's colour circle it, one
// for each ricochet it has left.
if (kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_MINE)
renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA));
for (int i = 0; i < attack.bounces && kind != ATTACK_BEAM && !attack.isAttached; i++)
{
float angle = attack.time * PIP_SPIN + 2 * PI * i / attack.bounces;
Vec2 pip = attack.position + vec2_polar(angle, attack.radius + PIP_GAP);
renderer.rect_draw({std::round(pip.x - PIP_SIZE / 2), std::round(pip.y - PIP_SIZE / 2), PIP_SIZE, PIP_SIZE},
tint);
}
}
magic_draw();
tesla_draw();
}
// The Grimoire's teleport, drawn rather than sprited: a faint ring where its blast will go with a core swelling inside
// it while it gathers. (Its wisps are drawn with the other attacks.)
void Scene::magic_draw()
{
for (Attack& attack : game.sim.weapons.attacks)
{
AttackKind kind = attack.def()->kind;
Color color = tint_get(game, attack);
if (kind == ATTACK_TELEPORT && !attack.isDead)
{
float progress = std::min(1.0f, attack.time / attack.duration);
float area = attack.stats.area * attack.scale;
game.renderer.ring_draw(attack.position, area, color_fade(color, TELEPORT_RING_ALPHA));
game.renderer.circle_draw(attack.position, area * TELEPORT_CORE * progress, color_fade(color, progress));
}
}
}
// Each link is a jagged bolt whose kinks move every frame so it crackles.
void Scene::tesla_draw()
{
for (int i = 0; i < game.sim.fighterCount; i++)
for (Capsule& link : game.sim.weapons.chain_get(game.sim, game.sim.fighters[i]))
bolt_draw(game.renderer, {link.from, link.to, BOLT_WIDTH}, BOLT_JITTER, game.color_get(i));
}
// The Display Collision setting: every attack's hit shapes as the hit test sees them, and the boxes fighters and
// dummies are hit in, see-through in their owner's colour.
void Scene::collision_draw()
{
Renderer& renderer = game.renderer;
for (Attack& attack : game.sim.weapons.attacks)
{
Capsule shapes[WHIP_RAYS];
int count = shapes_get(game.sim, attack, shapes);
capsules_fill(renderer, shapes, count, color_fade(owner_color_get(game, attack), COLLISION_ALPHA));
}
for (int i = 0; i < game.sim.fighterCount; i++)
if (game.sim.fighters[i].isAlive)
renderer.rect_draw(game.sim.fighters[i].body.box, color_fade(game.color_get(i), COLLISION_ALPHA));
for (Dummy& dummy : game.sim.world.dummies)
if (dummy.tile < 0)
renderer.rect_draw(dummy.box, color_fade(WHITE, COLLISION_ALPHA));
}
// Explosions draw before the player, so their filled centre never hides anyone standing in the blast.
void Scene::blasts_draw()
{
for (Attack& attack : game.sim.weapons.attacks)
{
if (attack.def()->kind != ATTACK_EXPLOSION)
continue;
float progress = attack.time / attack.duration;
float radius = attack.stats.range * std::min(1.0f, progress * EXPLOSION_GROW);
Color color = color_dim(owner_color_get(game, attack), attack.bonus);
float fill = fill_get(attack.bonus);
Vec2 center = attack.position + jitter_get(fill);
game.renderer.circle_draw(center, radius, color_fade(color, 1 - progress));
if (attack.time > EXPLOSION_FLASH)
game.renderer.circle_draw(center, radius - EXPLOSION_OUTLINE,
color_fade(color_shade(color, fill), 1 - progress));
}
}
// How a sprite-based attack looks right now: its weapon, or the projectile or effect it fired. Shared by the live
// attack and its afterimages.
Pose Scene::attack_pose(Attack& attack)
{
const AttackDef& def = *attack.def();
float progress = attack.time / attack.duration;
float side = attack.isLeft ? -1 : 1;
Anm2& effects = game.sprites.effects;
Pose pose = {.art = &game.sprites.weapons, .animation = WEAPON_DEFS[attack.weapon].name};
Draw& draw = pose.placement;
draw = {.position = attack.position, .scale = {attack.scale, attack.scale * side}, .angle = attack.angle};
Color tint = tint_get(game, attack);
draw.color = tint;
draw.flash = fill_get(strength_get(attack, attack.tip));
draw.position = draw.position + jitter_get(draw.flash);
switch (def.kind)
{
case ATTACK_SWING:
draw.position = attack.origin;
break;
case ATTACK_BOOMERANG:
draw.scale.y = attack.scale;
draw.angle = attack.time * BOOMERANG_SPIN;
break;
case ATTACK_SLASH:
draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress)));
pose = {&effects, "Slash", draw};
break;
case ATTACK_ARROW:
{
float fade = std::clamp((attack.duration - attack.time) / ARROW_FADE, 0.0f, 1.0f);
draw.color = color_fade(tint, attack.isAttached ? fade * fade : 1);
pose = {&effects, "Arrow", draw};
break;
}
case ATTACK_ROCKET:
pose = {&effects, "Rocket", draw};
break;
case ATTACK_PUNCH:
draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress)));
pose = {&effects, "Punch", draw};
break;
case ATTACK_UPPERCUT:
// The fist is drawn rising, so it stands upright when aimed straight ahead and leans with the launch.
// Facing left mirrors it.
draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress)));
draw.scale = {attack.scale * side, attack.scale};
draw.angle = attack.angle + PI / 2;
pose = {&effects, "Uppercut", draw, attack.time * effects.fps};
break;
case ATTACK_BULLET:
pose = {&effects, "Slug", draw};
break;
case ATTACK_PROJECTILE:
pose = {&effects, "Bullet", draw};
break;
case ATTACK_HOOK:
pose.animation = "Grapple Tip";
break;
case ATTACK_MINE:
draw.scale.y = attack.scale;
draw.angle = 0;
if (is_mine_armed(attack) && std::fmod(attack.time, MINE_BLINK) < MINE_BLINK_ON)
draw.flash = 1;
pose = {&effects, "Mine", draw};
break;
case ATTACK_FLAME:
case ATTACK_FIREBALL:
{
// Flames flicker through their animation, the spread jitter staggering them. Loose flames grow and fade
// out. Fireballs point their tip back along the path.
bool isFlame = def.kind == ATTACK_FLAME;
float size = attack.scale * (isFlame ? FLAME_SHRINK + FLAME_GROW * progress : 1);
draw.scale = {size, size};
draw.angle = isFlame ? 0 : attack.angle - PI / 2;
if (isFlame)
draw.color = color_fade(tint, std::min(1.0f, (1 - progress) * FLAME_FADE));
pose = {&effects, "Flame", draw, (attack.time + attack.aimAngle * FLAME_STAGGER) * effects.fps};
break;
}
case ATTACK_SPIN:
case ATTACK_FLAIL:
pose = {&effects, "Flail Ball", draw};
break;
case ATTACK_WISP:
{
// Wisps burn like little flames while they hover, tails flickering upward, then streak head-first along
// their path, each a little out of step with the rest.
bool isFlying = attack.velocity.x != 0 || attack.velocity.y != 0;
draw.scale = {attack.scale, attack.scale};
draw.angle = isFlying ? attack.angle - PI / 2 : 0;
pose = {&effects, "Wisp", draw, (attack.time + attack.slot * WISP_FRAME_STAGGER) * effects.fps};
break;
}
case ATTACK_TELEPORT:
// Drawn as its marker and sparkles instead.
pose.art = nullptr;
break;
default:
break;
}
return pose;
}
// Draws the selected weapon in the player's hand, aimed at the cursor, unless it is out attacking. It fills in as it
// charges (see charge_fill_get) and shakes with its charge and cooldown (see weapon_shake_get). A charging mine weapon
// holds up the mine it's about to place instead.
void Scene::held_draw(Fighter& fighter)
{
int held = fighter.weapon_key(fighter.slot);
if (held < 0 || (game.sim.weapons.is_busy(fighter, held) && !game.sim.weapons.tether_get(fighter, held, 0)))
return;
float side = fighter.is_left() ? -1 : 1;
Draw draw = {
.color = game.color_get(fighter.index),
.position = fighter.hand_get() + weapon_shake_get(fighter, held),
.scale = {1, side},
.angle = vec2_angle(fighter.aim) + kick_get(fighter) * side,
.flash = charge_fill_get(fighter),
};
if (fighter.charge > 0 && WEAPON_DEFS[fighter.weaponLoadout[held]].charged.kind == ATTACK_MINE)
{
draw.scale = {1, 1};
draw.angle = 0;
game.sprites.effects.draw(game.renderer, "Mine", 0, draw);
return;
}
reload_pose(fighter, held, draw);
hand_draw(fighter, held, 0, draw);
}
// Dual Wield: the off-hand weapons, a darker shade of the fighter's colour, fanned out behind it (see
// Fighter::hand_get), the furthest back first so the nearer cover them. Each shakes and reloads like the main one.
void Scene::offhand_draw(Fighter& fighter)
{
int offs[SLOT_MAX];
float side = fighter.is_left() ? -1 : 1;
for (int hand = fighter.off_slots_get(offs); hand >= 1; hand--)
{
int off = fighter.weapon_key(offs[hand - 1]);
if (game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, hand))
continue;
Draw draw = {
.color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE),
.position = fighter.hand_get(hand) + weapon_shake_get(fighter, off),
.scale = {1, side},
.angle = vec2_angle(fighter.aim),
.flash = charge_fill_get(fighter),
};
reload_pose(fighter, off, draw);
hand_draw(fighter, off, hand, draw);
}
}
// Draws a weapon in a hand. While its whip or hook is out, the hand holds only its base ("<name> Base"), pointing
// down the lash or rope.
void Scene::hand_draw(Fighter& fighter, int weaponSlot, int hand, Draw draw)
{
Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, hand);
std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name;
if (tether)
{
Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position;
draw.angle = vec2_angle(end - draw.position);
animation += " Base";
}
// A weapon without held art yet shows its icon, small and upright.
if (!game.sprites.weapons.animation_get(animation.c_str()))
{
draw.scale = {HELD_ICON_SCALE * (draw.scale.y < 0 ? -1 : 1), HELD_ICON_SCALE};
draw.angle = 0;
game.sprites.icons.draw(game.renderer, animation.c_str(), 0, draw);
return;
}
game.sprites.weapons.draw(game.renderer, animation.c_str(), 0, draw);
}