Files
smack-a-brother/src/scene.cpp
T
shweet 93cdf46d5a Room and lobby chat, /host, /fashion, player-colored lines, context tips, and American spelling
- Chat goes to your room (the sandbox, or your game) by default, and to the whole lobby with /all or a leading !.
  Lines from elsewhere say where they came from ([Sandbox], [Game 2]), lobby-wide ones from your room say [All].
  Typing shows only to your room. Protocol 8.
- Hosting: everyone is told who hosts now, and the host can hand it over with /host "player".
- /fashion <item> wears a fashion locally, like /color.
- Lines that mention a player show in that player's color, looked up as they draw.
- Tips are tagged with the item and situations they fit, and those that fit are 1.5x as likely. They turn over every
  10 seconds on one continuous run, show under the menu too, and can be turned off with Tips in HUD / Gameplay. The
  HUD hotbars hide while the menu is up (except on its item tabs).
- American spelling throughout: color, center, gray, mustache, and so on.
2026-11-10 19:37:19 -05:00

1319 lines
56 KiB
C++

#include "scene.h"
#include <bit>
#include "game.h"
#include <SDL3/SDL_timer.h>
#include <algorithm>
#include <cmath>
#include <string>
// Rumble: the damage taken that shakes a gamepad fully, and how long, and the lighter tick for landing a hit.
constexpr float RUMBLE_FULL_DAMAGE = 60;
constexpr Uint32 RUMBLE_HIT_TIME = 180;
constexpr float RUMBLE_LANDED = 0.15f;
constexpr Uint32 RUMBLE_LANDED_TIME = 60;
constexpr int BLINK_MILLISECONDS = 80;
// How long a claimed shot takes to turn from its old owner's color to its new one's.
constexpr float CLAIM_BLEND = 0.35f;
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 CONE_ALPHA = 0.35f;
constexpr float CONE_FILL_ALPHA = 0.3f;
// The air a Vacuum sucks in: how many motes stream along a cone at once, how long each takes from the far end to the
// muzzle, how much of that it spends fading in and out, and their size.
constexpr int SUCK_MOTES = 14;
constexpr float SUCK_TRAVEL = 0.4f;
constexpr float SUCK_FADE_IN = 0.25f;
constexpr float SUCK_FADE_OUT = 0.2f;
constexpr float SUCK_MOTE_SIZE = 2;
// How seeds for motes of air are spread and mixed (see mote_share_get).
constexpr Uint32 MOTE_KEYS = 16;
constexpr Uint32 MOTE_MIX_A = 0x7feb352d;
constexpr Uint32 MOTE_MIX_B = 0x846ca68b;
// How many motes an airblast blows out, and the least share of the cone's reach one gets to.
constexpr int GUST_MOTES = 20;
constexpr float GUST_REACH_MIN = 0.35f;
// How late (as a share of the blast's life) a mote may set off.
constexpr float GUST_STAGGER = 0.35f;
// How much a running Vacuum shakes in the hand at full spin.
constexpr float WHIR_SHAKE = 0.6f;
// How bright a weapon (and its crosshair) is just after firing, brightening back as it cools down.
constexpr float COOLDOWN_DARK = 0.35f;
// A fighter's whir voice when its Vacuum isn't running, or is running without a voice to play on.
constexpr int WHIR_NONE = -1;
constexpr int WHIR_SILENT = -2;
constexpr int CONE_ARC_STEPS = 8;
constexpr float EXPLOSION_FLASH = 0.05f;
// How far apart a double donk's blast blinks.
constexpr float DONK_BLINK = 0.1f;
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 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 WHIP_TAPER = 0.35f;
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 color 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 color fills in. They take the shape's color
// 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 color decayed to match a weakened strength: drained toward gray 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 gray = (color.r + color.g + color.b) / 3;
Color washed = {math_lerp(gray, color.r, strength), math_lerp(gray, color.g, strength),
math_lerp(gray, color.b, strength), color.a};
return color_shade(washed, FALLOFF_DIM + (1 - FALLOFF_DIM) * strength);
}
// The owner's color (darker for secondary attacks), dimming as a shot's damage falls off.
static Color owner_color_get(Game& game, Attack& attack)
{
// A claimed shot turns from its old owner's color to its new one's.
Color color = game.color_get(attack.owner);
if (attack.ownerPrevious >= 0)
color = color_mix(game.color_get(attack.ownerPrevious), color,
std::min(1.0f, (attack.time - attack.claimTime) / CLAIM_BLEND));
return color_shade(color, 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;
// Its width at the end, as a share of its width at the start.
float taper = 1;
};
// A beam, rope or whip, each piece colored by the strength there, dimming along it the way damage falls off. 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 = 0;
float reach = falloff_reach_get(attack);
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] / reach);
float width = math_lerp(1, stroke.taper, distances[i] / distance);
if (layer == 0)
game.renderer.line_draw(points[i], points[i + 1], stroke.outer * width, color_dim(color, strength));
else
game.renderer.line_draw(points[i], points[i + 1], stroke.inner * width,
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 in bounds (left, top, right, bottom) that isInside says is in a shape, a row of runs at a time, so
// where parts of it overlap stays even.
template <typename Inside> static void shape_fill(Renderer& renderer, Rect bounds, Inside isInside, Color color)
{
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;
}
}
}
}
// Fills every pixel inside any of the capsules.
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;
};
shape_fill(renderer, bounds, isInside, color);
}
// A cone from apex toward tip, cone radians to either side, filled as a fan fading from color at the apex to the share
// of it the cone's strength keeps at its far end (see cone_strength_get).
static void cone_fill_draw(Renderer& renderer, Vec2 apex, Vec2 tip, float cone, Color color)
{
float angle = vec2_angle(tip - apex);
float range = vec2_length(tip - apex);
Color far = color_fade(color, VACUUM_EDGE);
Color colors[] = {color, far, far};
for (int i = 0; i < CONE_ARC_STEPS; i++)
{
Vec2 corners[] = {apex, apex + vec2_polar(angle + cone * (2.0f * i / CONE_ARC_STEPS - 1), range),
apex + vec2_polar(angle + cone * (2.0f * (i + 1) / CONE_ARC_STEPS - 1), range)};
renderer.triangle_draw(corners, colors);
}
}
// A cone from apex toward tip, cone radians to either side, as its two edges and the arc across its far end.
static void cone_outline_draw(Renderer& renderer, Vec2 apex, Vec2 tip, float cone, Color color)
{
float angle = vec2_angle(tip - apex);
float range = vec2_length(tip - apex);
Vec2 previous = apex;
for (int i = 0; i <= CONE_ARC_STEPS; i++)
{
Vec2 point = apex + vec2_polar(angle + cone * (2.0f * i / CONE_ARC_STEPS - 1), range);
renderer.line_draw(previous, point, 1, color);
previous = point;
}
renderer.line_draw(previous, apex, 1, color);
}
// 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.stat_get(STAT_ATTACK_RATE);
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 color: 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)
{
// A running continuous weapon shakes as hard as it's spun up.
float left = key < 0 ? 0 : cooldown_left_get(fighter, key);
Attack* running = key < 0 ? nullptr : game.sim.weapons.continuous_get(fighter, key, 0);
float spin = running && !running->isReturning ? running->length * WHIR_SHAKE : 0;
return jitter_get(std::max({fighter.charge, left * left, spin}) * CHARGE_SHAKE);
}
// A weapon's color (and its crosshair's) through its cooldown: darkened just after it fires (keeping its hue), back to
// color as it cools.
// One cooling too quickly to see doesn't fade, so rapid fire doesn't flicker.
Color Scene::cooldown_tint_get(Fighter& fighter, int key, Color color)
{
if (key < 0 || fighter.level_get(key).cooldown / fighter.stat_get(STAT_ATTACK_RATE) < COOLDOWN_VISIBLE)
return color;
return color_shade(color, 1 - cooldown_left_get(fighter, key) * (1 - COOLDOWN_DARK));
}
// Each fighter's Vacuum whirs while it runs: spinning up into a loop as it starts, winding down as it's let go. It
// follows the game's state each frame rather than cues, so replays can't leave a whir going.
void Scene::whirs_update()
{
for (int i = 0; i < MAX_FIGHTERS; i++)
{
bool isRunning = false;
for (Attack& attack : game.sim.weapons.attacks)
isRunning |= i < game.sim.fighterCount && attack.owner == i && !attack.isDead && !attack.isReturning &&
attack.def()->kind == ATTACK_VACUUM;
if (isRunning && whirVoices[i] == WHIR_NONE)
{
game.audio.play(SOUND_VACUUM_START);
whirVoices[i] = std::max(WHIR_SILENT, game.audio.loop_play(SOUND_VACUUM));
}
if (!isRunning && whirVoices[i] != WHIR_NONE)
{
game.audio.stop(whirVoices[i]);
whirVoices[i] = WHIR_NONE;
game.audio.play(SOUND_VACUUM_STOP);
}
}
}
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);
std::fill(std::begin(whirVoices), std::end(whirVoices), WHIR_NONE);
}
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);
}
// A shot's muzzle flash, which keeps to the muzzle of the weapon that fired it (see flash_follow).
void Scene::flash_spawn(Attack& attack, float size)
{
if (isMuted)
return;
game.effects.flash_spawn({.color = game.color_get(attack.owner),
.position = muzzle_get(attack),
.angle = attack.angle,
.size = size,
.owner = attack.owner,
.hand = attack.hand,
.weapon = attack.weapon});
}
void Scene::fighter_afterimage_spawn(Fighter& fighter)
{
if (!isMuted)
game.effects.afterimage_spawn(fighter_pose_get(fighter), game.fashion_get(fighter.index));
}
void Scene::attack_afterimage_spawn(Attack& attack)
{
if (!isMuted)
game.effects.afterimage_spawn(attack_pose(attack), FASHION_NONE);
}
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));
}
// A hit's number (unless they're turned off) and its share of the DPS meter, and with rumble on, a shake of this
// screen's gamepad: harder for more damage taken, and a light tick for a hit landed.
void Scene::number_spawn(Damage& damage)
{
if (isMuted)
return;
if (game.settings.isDamageShown)
game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner));
game.effects.dps_record(damage.amount);
if (!game.settings.isRumbling)
return;
int device = game.devices[game.local];
if (damage.target == game.local)
game.input.rumble_play(device, damage.amount / RUMBLE_FULL_DAMAGE, RUMBLE_HIT_TIME);
else if (damage.owner == game.local)
game.input.rumble_play(device, RUMBLE_LANDED, RUMBLE_LANDED_TIME);
}
// A level going up or down chimes, and outside the menu pops up over this screen's fighter.
void Scene::level_step(int step)
{
if (isMuted)
return;
game.audio.play(step > 0 ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN);
if (game.menu.isOpen)
return;
Fighter& fighter = game.local_get();
Vec2 head = {fighter.center_get().x, top_get(fighter)};
game.effects.popup_spawn(head, step > 0 ? "Level Up" : "Level Down", step > 0 ? 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;
}
// 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()
{
whirs_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, TINT_PLAIN));
game.audio.play(SOUND_SPLAT);
}
}
// The match's tomatoes as splats in their throwers' colors: 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, TINT_PLAIN),
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
// 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();
tomatoes_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;
if (path.cone > 0)
{
cone_outline_draw(game.renderer, path.steps.front(), path.steps.back(), path.cone,
color_fade(base, TRAJECTORY_ALPHA));
continue;
}
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 neighbor = world.tile_get(x + dx, y + dy);
return neighbor != TILE_SOLID && neighbor != 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);
game.sprites.fashion_draw(renderer, pose, game.fashion_get(fighter.index));
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 color: 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);
}
// The top of a fighter: its box's, or higher where what it wears can reach over it (at most, so it holds still as the
// fighter animates). Whatever shows over a fighter (its bars and name, chat, popups) sits above this.
float Scene::top_get(Fighter& fighter)
{
Rect box = fighter.body.box;
float reach = game.sprites.fashion_height_get(game.fashion_get(fighter.index)) * fighter.scale;
return std::min(box.y, box.y + box.h - reach);
}
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.progress_get();
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, slowly at first. It
// tapers toward its tip. 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.is_charged() ? progress : std::max(0.0f, (progress - PATH_OUT) / (1 - PATH_OUT));
float width = (1 - (attack.is_charged() ? fade : fade * fade)) * attack.scale;
std::vector<Vec2> points =
attack.is_charged() ? 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.is_charged() ? 0 : WHIP_JITTER,
attack.is_charged() ? 1 : WHIP_TAPER});
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 color circle it, one
// for each ricochet it has left.
if (attack.def()->isBursting && !attack.def()->isInstant)
renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA));
// A Vacuum's cone is outlined: steady while it sucks, fading as an airblast passes.
// Its fill fades toward the far end, as its strength does.
if (is_cone(*attack.def()))
{
Vec2 tip = attack.position + vec2_polar(attack.aimAngle, cone_reach_get(attack));
float alpha = kind == ATTACK_GUST ? 1 - progress : 1;
cone_fill_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_FILL_ALPHA));
if (kind == ATTACK_VACUUM)
suck_draw(attack, tint);
else
gust_draw(attack, tint);
cone_outline_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_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));
}
}
}
// A share from 0 to 1 that a mote of air draws from a seed, the same every frame (a bit-mixing hash, so neighboring
// seeds give unrelated shares).
static float mote_share_get(Uint32 seed)
{
seed ^= seed >> 16;
seed *= MOTE_MIX_A;
seed ^= seed >> 15;
seed *= MOTE_MIX_B;
seed ^= seed >> 16;
return (seed >> 8) / (float)(1u << 24);
}
// The air a Vacuum sucks in, drawn from its cone rather than spawned, so it always fits it as it swings about: motes
// set off from the far end, each along its own line in the cone, fade in, stream to the muzzle and fade out there,
// one after another on a steady clock. While sucks overlap, only the newest of a hand's draws them.
void Scene::suck_draw(Attack& attack, Color color)
{
float clock = SDL_GetTicks() / 1000.0f / SUCK_TRAVEL;
for (int i = 0; i < SUCK_MOTES; i++)
{
float phase = clock + (float)i / SUCK_MOTES;
float progress = phase - std::floor(phase);
Uint32 seed = ((Uint32)std::floor(phase) * SUCK_MOTES + i) * MOTE_KEYS + attack.owner;
float side = mote_share_get(seed) * 2 - 1;
float fade = std::min({1.0f, progress / SUCK_FADE_IN, (1 - progress) / SUCK_FADE_OUT});
Vec2 mote =
attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * (1 - progress));
Particle particle = {.color = color, .life = fade, .lifeMax = 1, .size = SUCK_MOTE_SIZE};
particle_draw(game.renderer, particle, mote);
}
}
// The air a Vacuum's airblast blows out, drawn from its cone like the suck's: motes, each along its own line in the
// cone, setting off a little apart and reaching their own share of the way to its far end, sweep out from the muzzle
// over the blast's life, fading as they go.
void Scene::gust_draw(Attack& attack, Color color)
{
float progress = std::min(1.0f, attack.progress_get());
Uint32 blast = std::bit_cast<Uint32>(attack.aimAngle) ^ attack.owner;
for (int i = 0; i < GUST_MOTES; i++)
{
Uint32 seed = blast + i * MOTE_KEYS;
float side = mote_share_get(seed) * 2 - 1;
float reach = GUST_REACH_MIN + (1 - GUST_REACH_MIN) * mote_share_get(seed + 1);
float delay = GUST_STAGGER * mote_share_get(seed + 2);
float own = std::clamp((progress - delay) / (1 - delay), 0.0f, 1.0f);
float sweep = 1 - (1 - own) * (1 - own);
Vec2 mote =
attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * reach * sweep);
Particle particle = {.color = color, .life = 1 - own, .lifeMax = 1, .size = SUCK_MOTE_SIZE};
particle_draw(game.renderer, particle, mote);
}
}
// Each link is a jagged bolt whose kinks move every frame so it crackles, one bolt width thicker per coil.
void Scene::tesla_draw()
{
for (int i = 0; i < game.sim.fighterCount; i++)
{
Fighter& fighter = game.sim.fighters[i];
float width = BOLT_WIDTH * fighter.trinket_count(TRINKET_TESLA);
for (Capsule& link : game.sim.weapons.chain_get(game.sim, fighter))
bolt_draw(game.renderer, {link.from, link.to, width}, BOLT_JITTER, game.color_get(i));
}
}
// The Display Collision setting: every attack's hit shapes as the hit test sees them (and a Vacuum's whole cone, where
// it pulls or blows), and the boxes fighters and dummies are hit in, see-through in their owner's color.
void Scene::collision_draw()
{
Renderer& renderer = game.renderer;
for (Attack& attack : game.sim.weapons.attacks)
{
Color color = color_fade(owner_color_get(game, attack), COLLISION_ALPHA);
Capsule shapes[WHIP_RAYS];
int count = shapes_get(game.sim, attack, shapes);
capsules_fill(renderer, shapes, count, color);
if (cone_get(attack) <= 0)
continue;
float range = cone_reach_get(attack);
Rect bounds = {attack.position.x - range, attack.position.y - range, attack.position.x + range,
attack.position.y + range};
shape_fill(renderer, bounds, [&](Vec2 point) { return is_in_cone(attack, point); }, color);
}
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 center never hides anyone standing in the blast. One that landed a
// double donk blinks bright as it lingers, like an armed mine.
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, attack.time / EXPLOSION_DEF.duration * EXPLOSION_GROW);
Color color = color_dim(owner_color_get(game, attack), attack.bonus);
bool isLit = attack.isDonked && std::fmod(attack.time, DONK_BLINK) < DONK_BLINK * MINE_BLINK_LIT * 2;
float fill = isLit ? 1 : 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.progress_get();
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.lifetime - attack.life_get()) / 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_armed(attack) && attack.blink > mine_blink_get(attack) * (1 - MINE_BLINK_LIT))
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:
case ATTACK_VACUUM:
case ATTACK_GUST:
// Drawn as its marker and sparkles, or as rushing air, instead.
pose.art = nullptr;
break;
case ATTACK_GRENADE:
draw.scale.y = attack.scale;
pose = {&effects, "Grenade", draw};
break;
case ATTACK_BOLT:
// Upright and unflipped, whichever way it crawls.
draw.angle = 0;
draw.scale = {attack.scale, attack.scale};
pose = {&effects, "Bolt", draw, attack.time * effects.fps};
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 = cooldown_tint_get(fighter, held, 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);
}
// Keeps a muzzle flash on the muzzle of the weapon that fired it as that weapon moves after, as it's drawn (the main
// hand's kicking back with its recoil). Once the fighter is gone or that hand holds something else, it stays put.
void Scene::flash_follow(Flash& flash)
{
if (flash.owner >= game.sim.fighterCount)
return;
Fighter& fighter = game.sim.fighters[flash.owner];
int slot = fighter.hand_slot_get(flash.hand);
if (!fighter.isAlive || slot < 0 || fighter.weaponLoadout[slot] != flash.weapon)
return;
float side = fighter.is_left() ? -1 : 1;
float kick = flash.hand == 0 ? kick_get(fighter) * side : 0;
Attack probe = {.origin = fighter.hand_get(flash.hand),
.angle = vec2_angle(fighter.aim) + kick,
.weapon = flash.weapon,
.isLeft = fighter.is_left()};
flash.position = muzzle_get(probe);
flash.angle = probe.angle;
}
// Dual Wield: the off-hand weapons, a darker shade of the fighter's color, 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 = cooldown_tint_get(fighter, off, 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)
{
// A weapon's own animation idles, looping. Its charge animation (if it has one) shows how far it's charged, and its
// cooldown one how far it's cooled down, their frames stepping through and holding on the last once full.
Anm2& art = game.sprites.weapons;
Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, hand);
std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name;
std::string charging = animation + " Charge";
std::string cooling = animation + " Cooldown";
Anm2Animation* charge = fighter.charge > 0 ? art.animation_get(charging.c_str()) : nullptr;
Anm2Animation* cooldown = fighter.cooldowns[weaponSlot] > 0 ? art.animation_get(cooling.c_str()) : nullptr;
float frame = SDL_GetTicks() * art.fps / SDL_MS_PER_SECOND;
if (tether)
{
draw.angle = vec2_angle(tether->position - draw.position);
animation += " Base";
}
else if (charge)
{
animation = charging;
frame = std::min(1.0f, fighter.charge) * (charge->length - 1);
}
else if (cooldown)
{
animation = cooling;
frame = (1 - cooldown_left_get(fighter, weaponSlot)) * (cooldown->length - 1);
}
// A weapon without held art yet shows its icon, small and upright.
if (!art.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;
}
art.draw(game.renderer, animation.c_str(), frame, draw);
}