#include "scene.h" #include #include "game.h" #include #include #include #include // 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 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 points; std::vector 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 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 points = attack.is_charged() ? std::vector{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(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 (" 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); }