Split the simulation from presentation into an SDL-free library

Sim holds one whole game (tick, snapshots, checksum) and talks to the screen only
through Cues, which the client's Scene plays and the server ignores. All drawing
moved to scene.cpp. Match takes this screen's wishes as request flags instead of
reading the menu, input and settings. The sim library builds without SDL and the
server links it, ready to run games itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shweetandClaude Opus 5.5 committed 2026-11-08 01:46:07 -05:00
1 parent 2007fb7edc
commit 465f75f6d7
38 files changed
+2225 -1965

No files matched your search

+975
View File
@@ -0,0 +1,975 @@
#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 float ARROW_FADE = 0.6f;
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 = 4;
constexpr float BOLT_SEGMENT = 8;
constexpr float BOLT_WIDTH = 2;
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;
// 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);
}
// 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, float cooldown, Draw& draw)
{
if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || cooldown <= 0)
return;
float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1);
float t = std::clamp((1 - cooldown / total - 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 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.
static float charge_fill_get(Game& game, Fighter& fighter)
{
float buff = game.sim.weapons.buff_get(fighter);
if (fighter.charge >= 1)
return game.scene.is_flashing(fighter) ? 1.0f : buff;
return std::max(buff, fighter.charge);
}
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::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;
}
void Scene::choice_open()
{
if (game.local_count() == 1)
game.menu.choice_open(game);
}
void Scene::choice_close()
{
if (game.local_count() > 0)
game.menu.isOpen = false;
}
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 = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get();
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;
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 harder the further the charge has got. 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, false)))
return;
float side = fighter.is_left() ? -1 : 1;
Draw draw = {
.color = game.color_get(fighter.index),
.position = fighter.hand_get() + jitter_get(fighter.charge * CHARGE_SHAKE),
.scale = {1, side},
.angle = vec2_angle(fighter.aim) + kick_get(fighter) * side,
.flash = charge_fill_get(game, 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, fighter.cooldowns[held], draw);
hand_draw(fighter, held, false, draw);
}
// Dual Wield: the off-hand weapon, a darker shade of the fighter's colour, held in the far hand behind it.
void Scene::offhand_draw(Fighter& fighter)
{
int off = fighter.off_slot_get();
off = off < 0 ? -1 : fighter.weapon_key(off);
if (!fighter.is_trinket_equipped(TRINKET_DUAL) || off < 0 ||
(game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, true)))
return;
float side = fighter.is_left() ? -1 : 1;
Draw draw = {
.color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE),
.position = fighter.offhand_get() + jitter_get(fighter.charge * CHARGE_SHAKE),
.scale = {1, side},
.angle = vec2_angle(fighter.aim),
.flash = charge_fill_get(game, fighter),
};
reload_pose(fighter, off, fighter.cooldowns[off], draw);
hand_draw(fighter, off, true, 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, bool isOffhand, Draw draw)
{
Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, isOffhand);
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);
}