#include "weapon.h" #include "sim.h" #include #include #include #include constexpr int ATTACKS_MAX = 255; constexpr int DAMAGE_STEP = 5; constexpr int TESLA_LINKS = 5; constexpr float TESLA_INTERVAL = 0.2f; constexpr float TESLA_RADIUS = 2; constexpr float DEGREE = PI / 180; constexpr float ARROW_LINGER = 2.5f; constexpr float BOOMERANG_STEP = 2; constexpr float CASING_BACK = 0.4f; constexpr float CAPSULE_STEP = 4; constexpr float CATCH_DISTANCE = 10; constexpr float DOUBLER_DELAY = 0.15f; constexpr float FALLOFF_FLOOR = 0.1f; // How far along a beam its damage bottoms out. constexpr float BEAM_FADE_REACH = 540; constexpr float FILL_STRENGTH = 2; constexpr float TRAJECTORY_REACH = 400; constexpr float TRAJECTORY_STEP = 4; constexpr float FIRE_SHAKE = 0.5f; constexpr float HIT_INTERVAL = 0.25f; constexpr float HITSTOP_MAX = 3; constexpr float HITSTOP_PER_DAMAGE = 0.002f; constexpr float HITSTOP_MIN_DAMAGE = 10; constexpr float HIT_PARTICLES_PER_DAMAGE = 0.2f; constexpr float HAMMER_REBOUND = 380; constexpr float HAMMER_REBOUND_CHARGED = 1.5f; constexpr float KNOCKBACK_REACH = 1.5f; constexpr float KNOCKBACK_SPEED = 550; constexpr float LANCE_LENGTH = 40; constexpr float MUZZLE_DISTANCE = 18; constexpr float PROJECTILE_STEP = 8; constexpr float RAY_STEP = 2; constexpr float RECOIL_CURVE = 0.75f; constexpr float ROCKET_TAIL = 7; constexpr float SLASH_DISTANCE = 12; constexpr float SLASH_GAP = 14; constexpr float SHAKE_PER_DAMAGE = 0.003f; constexpr float SLAM_RATIO = 1.05f; constexpr float SPEED_DAMAGE = 0.05f; // A normal attack let go partway through a charge hits harder, up to this much more just short of full. constexpr float PARTIAL_CHARGE_BONUS = 0.5f; constexpr float SPEED_UNIT = 100; constexpr float CONTACT_INTERVAL = 0.4f; constexpr float WISP_GLIDE = 14; // How near its fighter's cursor a homing shot stops turning toward it. constexpr float HOMING_MIN = 10; constexpr float WISP_LAUNCH_STAGGER = 0.06f; constexpr float WISP_RELEASE_SPAN = 0.6f; constexpr float WISP_RING = 18; constexpr float WISP_RING_GAP = 10; constexpr float WISP_SPACING = 12; constexpr float WISP_SPIN = 2.5f; constexpr float TELEPORT_PROBE = 4; constexpr float TELEPORT_STEP = 2; constexpr float CONTACT_KNOCK = 1; constexpr float SPLASH_EDGE = 0.25f; constexpr float SPLIT_SCALE = 0.75f; constexpr float SPLIT_GAP = 0.06f; constexpr float SPLIT_GAP_MELEE = 0.5f; constexpr float SPLIT_GAP_THROWN = 0.3f; constexpr float UPPERCUT_REACH = 16; constexpr float VOLLEY_GAP = 6; constexpr float WHIP_CONE = 0.25f; constexpr float WHIP_HANDLE = 4; // A plain lash's motion, as shares of its lifetime: the handle draws back until WHIP_WINDUP, drives forward until // WHIP_THROW and stops, the loop that sends off reaches the tip as it cracks at PATH_OUT, and from WHIP_SETTLE the // hand draws it back in. Its heading is in radians off the aim, toward the side it rolls over. constexpr int WHIP_LINKS = 16; constexpr float WHIP_WINDUP = 0.12f; constexpr float WHIP_THROW = 0.24f; constexpr float WHIP_SETTLE = 0.6f; constexpr float WHIP_RAISED = 1.6f; constexpr float WHIP_REAR = 2.5f; constexpr float WHIP_DROOP = -0.9f; constexpr float WHIP_TAPER = 0.7f; constexpr float WHIP_KICK = 2.6f; constexpr float WHIP_WOBBLES = 1; constexpr float WHIP_DAMPING = 0.02f; constexpr float WHIP_SAG = 0.6f; constexpr float WHIP_PULL = 220; // How far a push tips a dummy: its sideways speed into lean speed for a sudden push (a hit or a blast), and its // sideways acceleration into lean acceleration for a steady one (a Vacuum's pull). constexpr float WOBBLE_PER_SPEED = 0.005f; constexpr float WOBBLE_PER_PULL = 0.05f; // Vacuum: how fast its pull speeds fighters and shots up toward it, how much of an airblast's knock is left at the edge // of its reach, and how much of it shoves shots. constexpr float VACUUM_PULL_RATE = 900; // How many targets an attack can remember striking (one bit each). constexpr int STRUCK_MAX = 32; constexpr float GUST_SHOT_SHARE = 0.16f; // How much of their spawned life (and range, and falloff) the owner's shots in a Vacuum's cone get back: each time its // suck nibbles, and all of it from an airblast. constexpr float VACUUM_RESTORE = 0.1f; constexpr float GUST_RESTORE = 1; // How long a Vacuum's suck takes to spin up to its full reach, and to wind back down once let go. constexpr float VACUUM_SPIN_UP = 0.25f; constexpr float VACUUM_SPIN_DOWN = 0.2f; // Grenades: their body's size, how much of a hard knock off a surface they keep, how hard a knock has to be to bounce, // rolling friction, and the fuse each Ricochet adds. // How much of a Vacuum's drift a resting mine or a boomerang keeps each tick. // How slow an arming shot must be going to come to rest. constexpr float REST_SPEED = 40; // How fast a shot flies home, against the speed it left at. constexpr float RETURN_SHARE = 0.5f; // How hard a bouncing shot must hit a surface to bounce off it. constexpr float SHOT_BOUNCE_MIN = 60; // How much of its blast a grenade deals to whoever it clonks on the way. constexpr float GRENADE_CONTACT_SHARE = 0.2f; // The double donk: how soon after a marking hit (a grenade's clonk) a blast from the same weapon must land, how much // harder it hits, and how long its blast lingers, flashing. constexpr float DONK_WINDOW = 0.25f; constexpr float DONK_DAMAGE = 3; constexpr float DONK_FLASH = 0.7f; // How fast a bouncing shot turns as it rolls. constexpr float ROLL_SPIN = 3; // Bolts: their size, gravity (heavier when charged), how far one crawls in a step, how many turns it tries a step // (from into its surface round to away from it), how far it reaches for a surface on landing, and how long one lasts at // most. constexpr float CRAWL_STEP = 2; constexpr int CRAWL_TURNS = 12; constexpr float CRAWL_SNAP = 10; constexpr Burst CASING_BURST = {.gravity = 700, .life = 0.6f, .size = 1, .speed = 110, .spread = 0.35f, .count = 1}; constexpr Burst BLAST_BURST = {.gravity = 200, .life = 0.5f, .size = 4, .speed = 220, .count = 20}; constexpr Burst HIT_BURST = {.gravity = 300, .speed = 180, .spread = PI / 2, .count = 6}; constexpr Burst IMPACT_BURST = {.speed = 80, .count = 5}; constexpr Burst SLAM_BURST = { .angle = -PI / 2, .gravity = 600, .life = 0.6f, .size = 4, .speed = 260, .spread = PI / 2.5f, .count = 24}; constexpr Burst SMOKE_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.4f, .count = 1}; constexpr Burst SPARK_BURST = {.speed = 200, .spread = PI / 2, .count = 12}; constexpr Burst TRAIL_BURST = {.life = 0.25f, .size = 2, .speed = 120, .spread = 0.2f, .count = 2}; constexpr Burst WARP_BURST = {.gravity = -60, .life = 0.4f, .size = 2, .speed = 50, .spread = PI, .count = 1}; constexpr Burst PORTAL_BURST = {.life = 0.4f, .size = 3, .speed = 140, .spread = PI, .count = 14}; // Flies a thrown weapon back toward the hand, reporting whether it got there. static bool is_caught(Fighter& fighter, Attack& attack) { Vec2 toHand = fighter.hand_get() - attack.position; float distance = vec2_length(toHand); attack.position = attack.position + vec2_normalize(toHand) * std::min(distance, attack.stats.speed * TICK); return distance < CATCH_DISTANCE; } // Moves a returning shot one tick toward its fighter's hand at half the speed it left at, sliding along any surface in // the way (trying each axis alone when the straight step is blocked). Reports whether it's done: caught, or wedged with // nowhere to go, which breaks it off with a burst. static bool shot_return(Sim& sim, Fighter& fighter, Attack& attack) { Vec2 toHand = fighter.hand_get(attack.hand) - attack.position; float distance = vec2_length(toHand); if (distance < CATCH_DISTANCE) return true; // Small steps, so it can't skip through a thin platform either. float travel = std::min(distance, attack.stats.speed * RETURN_SHARE * TICK); int steps = travel / BOOMERANG_STEP + 1; Vec2 step = vec2_normalize(toHand) * (travel / steps); for (int i = 0; i < steps; i++) { bool isMoved = false; for (Vec2 tried : {step, Vec2{step.x, 0}, Vec2{0, step.y}}) { Vec2 next = attack.position + tried; if ((tried.x == 0 && tried.y == 0) || sim.world.is_blocking_at(next)) continue; attack.position = next; isMoved = true; break; } if (isMoved) continue; sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); return true; } return false; } // Bounces a direction off the wall between from (open) and to (solid), flipping whichever axis crossed into it, or // mirroring it off a ramp's surface. static Vec2 reflect_get(Sim& sim, Vec2 from, Vec2 to, Vec2 direction) { Vec2 normal = sim.world.normal_get(to); if (normal.x != 0 || normal.y != 0) return direction - normal * (2 * vec2_dot(direction, normal)); bool isWallX = sim.world.is_blocking_at({to.x, from.y}); bool isWallY = sim.world.is_blocking_at({from.x, to.y}); if (!isWallX && !isWallY) { isWallX = true; isWallY = true; } return {isWallX ? -direction.x : direction.x, isWallY ? -direction.y : direction.y}; } // Plays the sound of a projectile striking a wall or a target. Hooks latch and rockets and fireballs explode // with their own sounds. static void impact_play(Sim& sim, AttackKind kind) { switch (kind) { case ATTACK_ARROW: sim.cues->sound_play(SOUND_IMPACT_ARROW); break; case ATTACK_BOOMERANG: sim.cues->sound_play(SOUND_IMPACT_BOOMERANG); break; case ATTACK_BULLET: sim.cues->sound_play(SOUND_IMPACT_BULLET); break; case ATTACK_FLAIL: sim.cues->sound_play(SOUND_FLAIL_LAND); break; case ATTACK_GRENADE: sim.cues->sound_play(SOUND_GRENADE_BOUNCE); break; case ATTACK_MINE: sim.cues->sound_play(SOUND_IMPACT_MINE); break; case ATTACK_PROJECTILE: case ATTACK_WISP: sim.cues->sound_play(SOUND_IMPACT_BLASTER); break; default: break; } } // Spends one of an attack's ricochets on a bounce, adding to its later damage. Flames go quietly, as there are so // many. static void ricochet_count(Sim& sim, Attack& attack) { attack.bounces--; attack.ricochets++; if (attack.def()->kind == ATTACK_FLAME) return; sim.cues->sound_play(SOUND_RICOCHET); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } // Turns a projectile off the wall it's about to enter, starting its range, lifetime and damage falloff afresh from // there. static void ricochet(Sim& sim, Attack& attack, Vec2 next) { attack.velocity = reflect_get(sim, attack.position, next, attack.velocity); attack.angle = vec2_angle(attack.velocity); attack.origin = attack.position; attack.lifeStart = attack.time; ricochet_count(sim, attack); } // What stops an attack moving through the world: walls and platform tops for most, walls alone for wisps. using WallCheck = bool (World::*)(Vec2); // Moves an attack toward a point in small steps, so a fast one can't skip through a thin wall. It stops on the last // clear step, writing the first blocked one, and returns whether it reached a wall. static bool is_wall_reached(Sim& sim, Attack& attack, Vec2 to, Vec2& wall, WallCheck isWall = &World::is_blocking_at) { Vec2 from = attack.position; int steps = vec2_length(to - from) / PROJECTILE_STEP + 1; for (int step = 1; step <= steps; step++) { Vec2 point = vec2_lerp(from, to, (float)step / steps); if ((sim.world.*isWall)(point)) { wall = point; return true; } attack.position = point; } return false; } // Walks the capsule in small steps, writing the first point that touches the rect. static bool is_capsule_in_rect(Capsule capsule, Rect rect, Vec2& point) { Vec2 delta = capsule.to - capsule.from; int steps = vec2_length(delta) / CAPSULE_STEP + 1; for (int i = 0; i <= steps; i++) { Vec2 sample = capsule.from + delta * ((float)i / steps); Vec2 closest = {std::clamp(sample.x, rect.x, rect.x + rect.w), std::clamp(sample.y, rect.y, rect.y + rect.h)}; if (vec2_length(sample - closest) > capsule.radius) continue; point = closest; return true; } return false; } // Whether an attack's reach and end go by how long it has been out (see lifetime_get), which the Hourglass stretches: // one timed by a lifetime, a shot with falloff that flies (not an instant, which reaches its range at once), or one on // a rope or chain (whose length grows with its flight, see Weapons::is_spawned). static bool is_aging(const AttackDef& def, const AttackStats& stats) { return stats.lifetime > 0 || (def.isFalloff && !def.isInstant) || def.isTethered; } // How far an attack reaches as fired, its speed and lifetime grown by its fighter's trinkets (see reach_get). static float reach_at_get(Attack& attack) { const AttackDef& def = *attack.def(); if (attack.stats.range > 0 || def.path == PATH_ARC) return attack.stats.range; return attack.stats.speed * attack.lifetime * (def.path == PATH_RETURN ? PATH_OUT : 1); } // Ranged shots lose damage linearly over their fade: full at the muzzle, down to a floor at its end (a fraction of 1) // and past it. float falloff_at(float fraction) { return 1 - std::min(1.0f, fraction) * (1 - FALLOFF_FLOOR); } // How far along a beam or a hook's rope its damage bottoms out, or never along anything else. float falloff_reach_get(Attack& attack) { AttackKind kind = attack.def()->kind; if (kind == ATTACK_BEAM) return BEAM_FADE_REACH; return kind == ATTACK_HOOK ? attack.stats.range : RANGE_INFINITE; } // A shot's falloff goes by how long it has flown. A beam's goes by distance along it to the point, and a hook's by how // far it reached from where it was thrown. static float falloff_get(Attack& attack, Vec2 point) { AttackKind kind = attack.def()->kind; if (!attack.def()->isFalloff) return 1; if (kind == ATTACK_BEAM) return falloff_at(vec2_length(point - attack.position) / falloff_reach_get(attack)); if (kind == ATTACK_HOOK) return falloff_at(vec2_length(attack.position - attack.origin) / falloff_reach_get(attack)); return falloff_at(attack.life_get() / attack.fade); } // How long a shot flies before it ends: as long as its base speed takes to cover its range (so a faster one goes // further, and one that accelerates speeding up as it goes), unless its data gives one (a punch, a flame's linger, a // grenade's fuse, a mine's wait). static float lifetime_get(const AttackDef& def, AttackStats& stats) { AttackKind kind = def.kind; if (stats.lifetime > 0) return stats.lifetime; return stats.speed > 0 ? flight_time_get(def, stats.speed, stats.range) : 0; } float flight_time_get(const AttackDef& def, float speed, float distance) { if (def.accelerate <= 0) return distance / speed; float top = speed * def.speedup; float rise = (top - speed) / def.accelerate; float risen = (speed + top) / 2 * rise; if (distance > risen) return rise + (distance - risen) / top; return (std::sqrt(speed * speed + 2 * def.accelerate * distance) - speed) / def.accelerate; } // How far a rocket, mine or fireball's explosion reaches. It grows with trinkets but not with the attack's own size, so // a big fireball keeps its listed area. float blast_area_get(Attack& attack) { return attack.stats.area * attack.scale / attack.stats.scale; } // What ricochets and falloff do to an attack's damage at a point. static float multiplier_get(Attack& attack, Vec2 point) { return (1 + attack.ricochets * RICOCHET_BONUS) * falloff_get(attack, point); } // How hard an attack hits right now against its plain damage, with its speed bonus, ricochets and falloff. float strength_get(Attack& attack, Vec2 point) { return attack.bonus * multiplier_get(attack, point); } // A weapon's muzzle as weapons.anm2 places it: from the hand along the aim (x) and to its side (y, flipped facing // left). struct Muzzle { const char* weapon; float x; float y; }; // Each weapon's muzzle by its place in WEAPON_DEFS, from the Muzzle nulls in weapons.anm2 (read in at build time, see // sim.cmake), or MUZZLE_DISTANCE straight ahead for a weapon without one. static constexpr std::array MUZZLES = [] { constexpr Muzzle NULLS[] = {WEAPON_MUZZLES{nullptr, 0, 0}}; std::array muzzles; for (int weapon = 0; weapon < WEAPON_COUNT; weapon++) { muzzles[weapon] = {MUZZLE_DISTANCE, 0}; for (const Muzzle& null : NULLS) if (null.weapon && std::string_view(null.weapon) == WEAPON_DEFS[weapon].name) muzzles[weapon] = {null.x, null.y}; } return muzzles; }(); // Where an attack's shots leave its weapon, held at its origin and pointing along its angle: the weapon's muzzle (see // MUZZLES), turned to the angle and mirrored facing left. Vec2 muzzle_get(Attack& attack) { Vec2 muzzle = MUZZLES[attack.weapon]; float side = attack.isLeft ? -1 : 1; return attack.origin + vec2_polar(attack.angle, muzzle.x) + vec2_polar(attack.angle + PI / 2, muzzle.y * side); } // How much of an attack's black fill its color takes over, from none at plain damage to all at FILL_STRENGTH. float fill_get(float strength) { return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f); } // Where a plain lash's handle points at a share of its lifetime, off the aim (see WHIP_WINDUP): eased back slowly, // driven forward sharply over the top, held along the aim through the crack, then drooping. static float lash_heading_get(float time) { time = std::max(0.0f, time); if (time < WHIP_WINDUP) { float ease = 1 - time / WHIP_WINDUP; return math_lerp(WHIP_RAISED, WHIP_REAR, 1 - ease * ease); } if (time < WHIP_THROW) { float drive = (time - WHIP_WINDUP) / (WHIP_THROW - WHIP_WINDUP); return math_lerp(WHIP_REAR, 0, drive * drive * (3 - 2 * drive)); } if (time < PATH_OUT) return 0; return WHIP_DROOP * std::min(1.0f, (time - PATH_OUT) / (WHIP_SETTLE - PATH_OUT)); } // The way a heading off a plain lash's aim points, turning toward the side it rolls over. static Vec2 lash_direction_get(Attack& attack, float heading) { Vec2 forward = vec2_polar(attack.aimAngle, 1); Vec2 up = attack.isLeft ? Vec2{-forward.y, forward.x} : Vec2{forward.y, -forward.x}; return forward * math_cos(heading) + up * math_sin(heading); } // A plain lash as a chain of links from the end of its handle. Each link points where the handle pointed a moment // before, longer toward the tip but less so for the lighter links, so the handle's stop rolls a loop down it that // straightens the tip as it cracks. After, the tip snaps back over and wobbles out ever slower while the slack whip // sags, and the hand draws it in. std::vector lash_get(Attack& attack) { float time = attack.progress_get(); float recoil = std::max(0.0f, (time - PATH_OUT) / (1 - PATH_OUT)); float slack = time < PATH_OUT ? std::max(0.0f, 1 - time / WHIP_THROW) : std::min(1.0f, 2 * recoil); float kick = time < PATH_OUT ? 0 : WHIP_KICK * math_sin(2 * PI * WHIP_WOBBLES * std::sqrt(recoil)) * math_pow(WHIP_DAMPING, recoil); float drawn = time < WHIP_SETTLE ? 1 : 1 - (time - WHIP_SETTLE) / (1 - WHIP_SETTLE); float step = attack.length * drawn / WHIP_LINKS; Vec2 forward = vec2_polar(attack.aimAngle, 1); Vec2 up = lash_direction_get(attack, PI / 2); // Slack, it sags toward straight down: behind the hand while drawn back, ahead of it after the crack. float rest = time < PATH_OUT ? WHIP_REAR : 0; float down = rest + std::remainder(math_atan2(up.y, forward.y) - rest, 2 * PI); std::vector points = {attack.position}; for (int i = 0; i < WHIP_LINKS; i++) { float along = (i + 0.5f) / WHIP_LINKS; float heading = lash_heading_get(time - (PATH_OUT - WHIP_THROW) * math_pow(along, WHIP_TAPER)); heading += kick * along * along; heading += (down - heading) * WHIP_SAG * slack * along; points.push_back(points.back() + lash_direction_get(attack, heading) * step); } return points; } // A fighter other than the attacker that's still alive, any dummy, or a block that isn't broken. // Whether one fighter's attacks can touch another: always in a match, but in the sandbox only between two fighters that // both have PvP on. static bool is_hostile(Sim& sim, int owner, int target) { Match& match = sim.match; return match.state != MATCH_PRACTICE || (sim.fighters[owner].isPvp && sim.fighters[target].isPvp); } static bool is_target(Sim& sim, int target, int owner) { if (target >= MAX_FIGHTERS) { World& world = sim.world; int index = target - MAX_FIGHTERS; return index < (int)world.dummies.size() && (world.dummies[index].tile < 0 || world.tiles[world.dummies[index].tile] == TILE_BLOCK); } return target < sim.fighterCount && target != owner && sim.fighters[target].isAlive && is_hostile(sim, owner, target); } static Rect target_box(Sim& sim, int target) { return target < MAX_FIGHTERS ? sim.fighters[target].body.box : sim.world.dummies[target - MAX_FIGHTERS].box; } // Numbers part way from a level's normal attack to its charged one, damage kept to steps of 5. static AttackStats stats_blend(const LevelDef& level, float charge) { float damage = math_lerp(level.normal.damage, level.charged.damage, charge); return { .damage = std::round(damage / DAMAGE_STEP) * DAMAGE_STEP, .range = math_lerp(level.normal.range, level.charged.range, charge), .speed = math_lerp(level.normal.speed, level.charged.speed, charge), .volley = level.normal.volley, }; } // How far a Splitter copy turns from the aim. Copies sit a fixed gap apart, so the fan widens as the Splitter adds // copies: wide gaps for melee, narrower for thrown weapons, tight for shots, all narrowed by spread (Diazepam). Spins // share the circle evenly. static float split_offset(AttackKind kind, int split, int splits, float spread) { if (kind == ATTACK_SPIN) return 2 * PI * split / splits; float gap = SPLIT_GAP; if (kind == ATTACK_SWING || kind == ATTACK_SLASH || kind == ATTACK_POKE || kind == ATTACK_PUNCH || kind == ATTACK_UPPERCUT) gap = SPLIT_GAP_MELEE; if (kind == ATTACK_BOOMERANG || kind == ATTACK_FLAIL || kind == ATTACK_MINE) gap = SPLIT_GAP_THROWN; return gap * (split - (splits - 1) / 2.0f) * spread; } // Walks from start along angle until range runs out or isBlocked passes, returning where it stopped. static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked) { Vec2 point = start; while (vec2_length(point - start) < range && !isBlocked(point)) point = point + vec2_polar(angle, RAY_STEP); return point; } // The capsules an attack hits with right now, returning how many. A plain whip fans rays across its cone from the // hand, out as far ahead as its lash reaches and short of walls. Everything else is one capsule from position to tip. int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) { if (attack.def()->kind != ATTACK_WHIP || attack.is_charged()) { shapes[0] = {attack.position, attack.tip, attack.radius}; return 1; } auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; float front = 0; Vec2 forward = vec2_polar(attack.aimAngle, 1); for (Vec2 point : lash_get(attack)) front = std::max(front, vec2_dot(point - attack.origin, forward)); for (int ray = 0; ray < WHIP_RAYS; ray++) { float angle = attack.aimAngle + WHIP_CONE * (2.0f * ray / (WHIP_RAYS - 1) - 1); shapes[ray] = {attack.origin, ray_march(attack.origin, angle, front, isBlocked), attack.radius}; } return WHIP_RAYS; } // Fires the weapon in hand with the given charge if it's ready. Full charge uses the charged attack. // With the Doubler on, the same attack repeats a moment later for a fraction of the damage (stacked Doublers each // add a repeat of their own, one after another). With Dual Wield on, an off-hand weapon per copy fires too (see // Fighter::off_slots_get), each for a fraction of its damage. Every weapon (see Fighter::weapon_key) keeps its own // cooldown, in any hand, so swapping to another weapon never inherits the last one's wait. void Weapons::fire(Sim& sim, Fighter& fighter, float charge) { int held = fighter.weapon_key(fighter.slot); float gloves = fighter.stat_get(STAT_ATTACK_RATE); if (held >= 0 && fighter.cooldowns[held] <= 0 && !is_busy(fighter, held)) { // Its cooldown only starts when something fires (not a continuous weapon already going, nor one at its limit). bool isSpawned = is_spawned(sim, fighter, {.charge = charge, .weaponSlot = held}); fighter.cooldowns[held] = isSpawned ? fighter.level_get(held).cooldown / gloves : 0; // Each Doubler copy adds an echo, at its own level's power. for (int i = 0, repeat = 0; isSpawned && i < fighter.trinketSlots; i++) { if (fighter.trinketLoadout[i] != TRINKET_DOUBLER) continue; float power = TRINKET_DEFS[TRINKET_DOUBLER].levels[fighter.trinketSlotLevels[i]].repeatDamage; fighter.echoes.push_back({charge, DOUBLER_DELAY * ++repeat, power, held}); } } // Each Dual Wield copy fires one off-hand weapon at its own level's cut, the strongest cut going to the first. float powers[SLOT_MAX]; int copies = 0; for (int i = 0; i < fighter.trinketSlots; i++) if (fighter.trinketLoadout[i] == TRINKET_DUAL) powers[copies++] = TRINKET_DEFS[TRINKET_DUAL].levels[fighter.trinketSlotLevels[i]].offhandDamage; std::sort(powers, powers + copies, std::greater()); int offs[SLOT_MAX]; int count = fighter.off_slots_get(offs); for (int hand = 1; hand <= count; hand++) { int off = fighter.weapon_key(offs[hand - 1]); if (fighter.cooldowns[off] > 0 || is_busy(fighter, off)) continue; bool isSpawned = is_spawned( sim, fighter, {.charge = charge, .power = powers[hand - 1], .weaponSlot = off, .hand = hand, .isSecondary = true}); fighter.cooldowns[off] = isSpawned ? fighter.level_get(off).cooldown / gloves : 0; } } // Whether the fighter's box would fit with its middle at center, clear of walls (and inside the map). static bool is_box_clear(Sim& sim, Fighter& fighter, Vec2 center) { Rect box = fighter.body.box; int columns = (int)(box.w / TELEPORT_PROBE) + 1; int rows = (int)(box.h / TELEPORT_PROBE) + 1; for (int row = 0; row <= rows; row++) for (int column = 0; column <= columns; column++) { Vec2 point = {center.x - box.w / 2 + box.w * column / columns, center.y - box.h / 2 + box.h * row / rows}; if (sim.world.is_blocking_at(point)) return false; } return true; } // Where a teleport toward the cursor lands: the cursor, held within range of the fighter's middle, backed off toward // the fighter until its box fits there. Returns the fighter's own middle if nowhere fits. static Vec2 teleport_spot(Sim& sim, Fighter& fighter, Vec2 target, float range) { Vec2 center = fighter.center_get(); Vec2 offset = target - center; float distance = std::min(range, vec2_length(offset)); Vec2 direction = vec2_normalize(offset); for (float along = distance; along > 0; along -= TELEPORT_STEP) { Vec2 spot = center + direction * along; if (is_box_clear(sim, fighter, spot)) return spot; } return center; } // The fighter's speed along the aim that an inheriting shot gains. Launched fighters lend nothing. static float push_get(Fighter& fighter, Vec2 aim) { return fighter.isLaunched ? 0 : std::max(0.0f, vec2_dot(fighter.body.velocity, aim)); } // The damage multiplier a fighter's speed gives an attack fired now along aim, with the Speedometer adding to it. // Inheriting shots (which gain the part of the fighter's velocity along the aim, so running away never slows them) // count only that push, everything else the fighter's whole speed. static float bonus_get(Fighter& fighter, const AttackDef& def, Vec2 aim) { float speed = def.isInheriting ? push_get(fighter, aim) : vec2_length(fighter.body.velocity); return 1 + (SPEED_DAMAGE + fighter.stat_get(STAT_SPEED_DAMAGE)) * speed / SPEED_UNIT; } // How much a fighter's color fills in from the bonus its held weapon would get firing right now. float Weapons::buff_get(Fighter& fighter) { int held = fighter.weapon_get(); if (held < 0) return 0; return fill_get(bonus_get(fighter, WEAPON_DEFS[held].attack_get(variant_get(fighter.charge)), fighter.aim)); } // What an attack does for this fighter, mirroring is_spawned and damage_deal: Splitter copies hit for less and come // out smaller, Embiggener adds damage, Gloves and Energy Drink speed up attacking and charging, Enlarger grows blasts // and melee reach, the Accelerator speeds up projectiles (and so, like the Hourglass, carries timed shots further), // Diazepam tightens spread and Shock Absorber eases lunges. // "Every" is the cooldown, or for a charged attack the time to charge. AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, AttackVariant variant) { const WeaponDef& def = WEAPON_DEFS[weapon]; const AttackDef& attack = variant == VARIANT_CHARGED ? def.charged : def.normal; const LevelDef& levelDef = def.levels[level]; AttackStats stats = variant == VARIANT_CHARGED ? levelDef.charged : levelDef.normal; AttackKind kind = attack.kind; bool isTeleport = kind == ATTACK_TELEPORT; int splits = isTeleport ? 1 : (int)fighter.stat_get(STAT_COPIES); float splitScale = splits > 1 ? SPLIT_SCALE : 1; float embiggen = fighter.stat_get(STAT_DAMAGE); float enlarger = fighter.stat_get(STAT_ATTACK_SIZE); float gloves = fighter.stat_get(STAT_ATTACK_RATE); float drink = fighter.stat_get(STAT_CHARGE_RATE); float accelerator = attack.isProjectile ? fighter.stat_get(STAT_PROJECTILE_SPEED) : 1; float steadiness = 1 - fighter.stat_get(STAT_SPREAD_CUT); float absorb = fighter.stat_get(STAT_MOVEMENT); AttackSummary summary; summary.isDamaging = kind != ATTACK_DETONATE && stats.damage > 0; float damage = splits > 1 ? std::max(1.0f, std::round(stats.damage * fighter.stat_get(STAT_COPY_DAMAGE))) : stats.damage; summary.damage = std::round(damage * embiggen); summary.isDamageChanged = summary.damage != stats.damage; summary.every = variant == VARIANT_CHARGED ? CHARGE_TIME / drink : levelDef.cooldown / gloves; summary.isEveryChanged = variant == VARIANT_CHARGED ? drink != 1 : gloves != 1; float flight = is_aging(attack, stats) ? accelerator * fighter.stat_get(STAT_LIFETIME) : 1; float reach = reach_get(attack.path, stats); summary.range = reach * (attack.isRigid ? enlarger * splitScale : flight); summary.isRangeChanged = summary.range != reach; summary.area = stats.area * attack.scale * enlarger * splitScale; summary.isAreaChanged = enlarger != 1 || splits > 1; summary.speed = attack.isProjectile ? stats.speed * accelerator : 0; summary.isSpeedChanged = accelerator != 1; summary.spread = attack.spread * steadiness; summary.isSpreadChanged = steadiness != 1; summary.lunge = stats.lunge * absorb; summary.isLungeChanged = absorb != 1; summary.pieces = stats.volley * splits; summary.isPiecesChanged = splits > 1; // A detonator only sets off what's already out: one press, nothing of its own. if (kind == ATTACK_DETONATE) { summary.range = summary.area = summary.speed = 0; summary.pieces = 1; summary.isPiecesChanged = false; } if (levelDef.limit > 0 && variant != VARIANT_CHARGED) summary.out = levelDef.limit; else if (kind == ATTACK_BOOMERANG || kind == ATTACK_MINE) summary.out = levelDef.count; return summary; } // Builds and launches a weapon's attack, reporting whether anything fired. Every attack leaves a little off the aim, by // as much as its spread, which Diazepam steadies. Every fighter hits harder the faster it's moving (or, for // shots, the faster it moves along the aim), and the Speedometer adds to that. Trinkets speed up projectiles and grow // attacks here. Detonating sets off the weapon's mines instead, and mines won't place past the level's count. A level // with a limit fires only as many pieces as fit under it (see LevelDef). bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) { int key = shot.weaponSlot; int held = fighter.weaponLoadout[key]; float charge = shot.charge; float power = shot.power; const WeaponDef& weapon = WEAPON_DEFS[held]; const LevelDef& level = fighter.level_get(key); AttackVariant variant = variant_get(charge); const AttackDef& def = variant == VARIANT_CHARGED ? weapon.charged : weapon.normal; AttackStats stats = variant == VARIANT_CHARGED ? level.charged : level.normal; if (weapon.mode == FIRE_DRAWN) stats = stats_blend(level, charge); AttackKind kind = def.kind; int mines = 0; for (Attack& attack : attacks) mines += attack.def()->kind == ATTACK_MINE && attack.owner == fighter.index && attack.key == key && !attack.isDead && !attack.isCopy; if (kind == ATTACK_MINE && !shot.isEcho && mines >= level.count) return false; if (level.limit > 0 && !shot.isSecondary) { int out = 0; for (Attack& attack : attacks) out += attack.def() == &def && attack.owner == fighter.index && attack.key == key && !attack.isCopy && !attack.isSecondary && !attack.isDead; if (out >= level.limit) return false; stats.volley = std::min(stats.volley, level.limit - out); } // A continuous weapon only ever has one attack going from a hand (which keeps itself going while held), so firing // it again does nothing. bool isContinuing = weapon.mode == FIRE_CONTINUOUS && variant != VARIANT_CHARGED && !shot.isEcho; if (isContinuing && continuous_get(fighter, key, shot.hand)) return false; if (!shot.isSecondary) { fighter.kickFrom = weapon.kick; fighter.kickTo = weapon.follow; fighter.kickTime = 0; } if (kind == ATTACK_DETONATE) { // With nothing armed to set off, the detonator just clicks. bool isLive = false; for (Attack& attack : attacks) { if (!is_armed(attack) || attack.owner != fighter.index || attack.key != key || attack.isDead) continue; attack_explode(sim, attack); isLive = true; } sim.cues->sound_play(isLive ? def.sound : SOUND_DETONATE_EMPTY); return true; } float hourglass = is_aging(def, stats) ? fighter.stat_get(STAT_LIFETIME) : 1; float lifetime = lifetime_get(def, stats) * hourglass; float accelerator = def.isProjectile ? fighter.stat_get(STAT_PROJECTILE_SPEED) : 1; stats.speed *= accelerator; // A rope or chain is as long as its shot's flight carries it, so it lengthens as the flight does. if (def.isTethered) stats.range *= hourglass * accelerator; float spread = 1 - fighter.stat_get(STAT_SPREAD_CUT); float jitter = def.spread * DEGREE * spread; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.rng.random_get(-jitter, jitter), 1); float push = def.isInheriting ? push_get(fighter, aim) : 0; // A part-charged normal attack (not one whose charge already strengthens it its own way) adds to the speed bonus, // so it hits harder and fills in, glows and shakes the same way. float bonus = bonus_get(fighter, def, aim); if (variant != VARIANT_CHARGED && !weapon.isChargeScaled) bonus *= 1 + charge * PARTIAL_CHARGE_BONUS; stats.damage = std::max(1.0f, std::round(stats.damage * power * bonus)); Attack attack = { .stats = stats, .origin = fighter.hand_get(shot.hand), .velocity = aim * stats.speed, .aimAngle = vec2_angle(aim), .angle = vec2_angle(aim), .bonus = bonus, .duration = def.duration, .fade = stats.fade > 0 ? stats.fade * hourglass : lifetime, .lifetime = lifetime, .scale = def.scale * stats.scale, .bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0, .hitsLeft = def.hits, .owner = fighter.index, .weapon = held, .key = key, .hand = shot.hand, .variant = variant, .isLeft = fighter.is_left(), .isSecondary = shot.isSecondary, }; attack.position = muzzle_get(attack); // Charging burns a grenade's fuse down (fully charged, it goes off in the barrel, see below). if (kind == ATTACK_GRENADE) attack.lifetime = std::max(TICK, attack.lifetime * (1 - charge)); // A teleport is one spell however many copies anything else would make: it marks where it will land. if (kind == ATTACK_TELEPORT) { attack.position = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range); attack.origin = fighter.center_get(); attack.tip = attack.position; attack.hitsLeft = 0; attack.scale *= fighter.stat_get(STAT_ATTACK_SIZE); attack.isCopy = shot.isEcho; attack_remember(attack); attacks.push_back(attack); sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound); return true; } if (kind == ATTACK_UPPERCUT) attack.angle -= (fighter.is_left() ? -1 : 1) * PI / 2; attack.scale *= fighter.stat_get(STAT_ATTACK_SIZE); int splits = fighter.stat_get(STAT_COPIES); if (splits > 1) { attack.scale *= SPLIT_SCALE; attack.stats.damage = std::max(1.0f, std::round(attack.stats.damage * fighter.stat_get(STAT_COPY_DAMAGE))); } // A rigid attack reaches as far as it has grown or shrunk (Enlarger, Splitter). A Vacuum pulls and blows only as // hard as the shot's power (Doubler repeats, off hands). if (def.isRigid) attack.stats.range *= attack.scale / (def.scale * stats.scale); if (is_cone(def)) attack.stats.speed *= power; // One piece per volley shot per split, each fanned around the aim (or set side by side when the volley has no // fan). The center split stays primary and the rest are secondary. for (int v = 0; v < stats.volley; v++) { float place = stats.volley > 1 ? (float)v / (stats.volley - 1) - 0.5f : 0; for (int split = 0; split < splits; split++) { float offset = 0; if (stats.volley > 1) offset += (stats.fan * place + sim.rng.random_get(-def.scatter, def.scatter) * DEGREE) * spread; if (splits > 1) offset += split_offset(kind, split, splits, spread); Attack piece = attack; if (stats.fan <= 0) piece.origin = piece.origin + vec2_polar(attack.angle + PI / 2, VOLLEY_GAP * (stats.volley - 1) * place); piece.angle += offset; piece.aimAngle += offset; piece.velocity = vec2_polar(piece.angle, stats.speed + push); // A slash sets off a little ahead of the hand, clear of its fighter. float slash = SLASH_DISTANCE * piece.scale + SLASH_GAP; piece.position = kind == ATTACK_SLASH ? piece.origin + vec2_polar(piece.angle, slash) : muzzle_get(piece); piece.isSecondary |= split != splits / 2; piece.isCopy = shot.isEcho || split != splits / 2; if (def.isOrbiting) { // Wisps rise out of the fighter's middle to join its orbit (see orbits_update). Cast with attack held // they gather there until it's let go (see orbits_release); otherwise each sets off a little after the // last. int index = v * splits + split; piece.isGathering = fighter.is_down(CONTROL_ATTACK); piece.slot = index; piece.duration = attack.duration + index * WISP_LAUNCH_STAGGER; piece.position = fighter.center_get(); piece.orbit = {}; piece.velocity = {}; // Until it sets off, its angle is only how far its spread (and any split) turns it off the aim. piece.angle = std::remainder(piece.aimAngle - vec2_angle(fighter.aim), 2 * PI); } // A fully charged grenade never leaves the barrel: it goes off right there, with no grenade to clonk anyone // first. if (kind == ATTACK_GRENADE && variant == VARIANT_CHARGED) { attack_explode(sim, piece); continue; } if (def.isInstant) instant_launch(sim, piece); if (kind == ATTACK_WHIP) whip_pull(sim, piece); attacks.push_back(piece); } } sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound); float side = fighter.is_left() ? -1 : 1; if (weapon.flash > 0) sim.cues->flash_spawn(attack, weapon.flash); if (weapon.isEjecting) sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), fighter.index); if (variant == VARIANT_CHARGED) sim.cues->shake_add(def.shake * FIRE_SHAKE); // An uppercut launches its fighter the way it rises, canceling any speed against that first. Shock Absorber // softens every push the shot gives its own fighter: lunges, kickback and Recoil Coil. float absorb = fighter.stat_get(STAT_MOVEMENT); Vec2 lunge = kind == ATTACK_UPPERCUT ? vec2_polar(attack.angle, 1) : aim; if (kind == ATTACK_UPPERCUT) fighter.body.velocity = fighter.body.velocity - lunge * std::min(0.0f, vec2_dot(fighter.body.velocity, lunge)); if (stats.lunge > 0) fighter.burst_add(lunge * stats.lunge * absorb); if (stats.lunge < 0) fighter.impulse_add(aim * stats.lunge * absorb); float recoil = fighter.stat_get(STAT_RECOIL) * math_pow(stats.damage * stats.volley, RECOIL_CURVE) * absorb; if (recoil > 0) fighter.impulse_add(fighter.aim * -recoil); return true; } void Weapons::update(Sim& sim) { for (int i = 0; i < sim.fighterCount; i++) contact_update(sim, sim.fighters[i]); for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.fighters[i]; if (fighter.hitstop > 0) continue; if (fighter.isAlive) sim.cues->fighter_aura_spawn(fighter); for (Echo& echo : fighter.echoes) { echo.timer -= TICK; if (echo.timer <= 0 && echo.weaponSlot == fighter.weapon_key(fighter.slot)) is_spawned(sim, fighter, { .charge = echo.charge, .power = echo.power, .weaponSlot = echo.weaponSlot, .isEcho = true, .isSecondary = true, }); } fighter.echoes.erase_if([](Echo& echo) { return echo.timer <= 0; }); } orbits_update(sim); // A fighter's attacks freeze with it in hitstop. for (Attack& attack : attacks) { if (sim.fighters[attack.owner].hitstop > 0) { attack_remember(attack); continue; } bool isNew = attack.time == 0; attack_remember(attack); attack_move(sim, attack); if (isNew) attack_remember(attack); attack.hitCooldown -= TICK; AttackKind kind = attack.def()->kind; bool isResting = (attack.velocity.x == 0 && attack.velocity.y == 0) || kind == ATTACK_FLAME; bool isLine = kind == ATTACK_BEAM || kind == ATTACK_WHIP; bool isAir = is_cone(*attack.def()); if (!isLine && !isAir && kind != ATTACK_EXPLOSION && !attack.isAttached && !isResting) sim.cues->attack_afterimage_spawn(attack); if (!attack.isAttached && !isAir) sim.cues->attack_aura_spawn(attack); // Dummies and blocks are checked before fighters, so racers crowding a block can't soak up every swing at it. Capsule shapes[WHIP_RAYS]; int shapeCount = shapes_get(sim, attack, shapes); int targets = MAX_FIGHTERS + sim.world.dummies.size(); for (int i = 0; i < targets; i++) { int target = (i + MAX_FIGHTERS) % targets; bool isStruck = target < STRUCK_MAX && (attack.struck >> target & 1); if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || isStruck || !is_target(sim, target, attack.owner)) continue; for (int shape = 0; shape < shapeCount; shape++) { Vec2 point; if (!is_capsule_in_rect(shapes[shape], target_box(sim, target), point)) continue; attack_hit(sim, attack, target, point); break; } } attack.time += TICK; } tesla_update(sim); attacks_cap(sim); std::erase_if(attacks, [](Attack& attack) { return attack.isDead; }); attacks.insert(attacks.end(), pending.begin(), pending.end()); pending.clear(); } // No fighter keeps more than ATTACKS_MAX attacks out: past that, their oldest loose ones (anything not anchored to the // fighter, not the weapon itself out of the hand and not an explosion going off, so mines, stuck arrows, wisps and // flames) fizzle out with a puff, oldest first. Attacks are kept in the order they were made, so the first found are // the oldest. void Weapons::attacks_cap(Sim& sim) { for (int i = 0; i < sim.fighterCount; i++) { int count = 0; for (Attack& attack : attacks) count += attack.owner == i && !attack.isDead; for (Attack& attack : attacks) { if (count <= ATTACKS_MAX) break; const AttackDef& def = *attack.def(); bool isHeld = def.anchor != ANCHOR_WORLD || def.isWielded || def.kind == ATTACK_EXPLOSION; if (attack.owner != i || attack.isDead || isHeld) continue; attack.isDead = true; count--; sim.cues->attack_burst_spawn(SMOKE_BURST, attack.position, 0, attack); } } } // Advances an attack by one tick according to its kind and updates its hit capsule. void Weapons::attack_move(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); Fighter& fighter = sim.fighters[attack.owner]; float progress = attack.progress_get(); float range = attack.stats.range; attack.radius = def.shape == SHAPE_CIRCLE ? attack.stats.range : def.radius * attack.scale; attack.hitsLeft = def.isInstant && attack.time > 0 ? 0 : attack.hitsLeft; if (def.anchor != ANCHOR_WORLD) path_move(sim, attack); else if (def.path != PATH_OWN) flight_move(sim, attack); // A mine, flail ball or grapple left out goes once its slot no longer holds the weapon that put it there (which // could no longer set it off, reel it in or let it go), a mine fizzling with a puff. bool isLeftOut = def.isArming || def.isTethered; if (isLeftOut && !attack.isExplosion && fighter.weaponLoadout[attack.key] != attack.weapon) { attack.isDead = true; if (def.isArming) sim.cues->attack_burst_spawn(SMOKE_BURST, attack.position, 0, attack); return; } switch (def.kind) { case ATTACK_SWING: { // The head sweeps its arc overhead (see path_move). // A head striking a wall stops the swinger's motion along the aim and boosts them back against it (aiming // down at the floor pops them up), once per swing. if (!attack.isRebounded && sim.world.is_blocking_at(attack.position)) { Vec2 away = vec2_polar(attack.aimAngle + PI, 1); float into = vec2_dot(fighter.body.velocity, away); fighter.body.velocity = fighter.body.velocity - away * std::min(0.0f, into); fighter.impulse_add(away * HAMMER_REBOUND * (attack.is_charged() ? HAMMER_REBOUND_CHARGED : 1) * fighter.stat_get(STAT_MOVEMENT)); attack.isRebounded = true; sim.cues->sound_play(SOUND_BOUNCE); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } if (!attack.isDead || !attack.is_charged()) break; attack.radius = range * SLAM_RATIO; sim.cues->sound_play(SOUND_SLAM); sim.cues->shake_add(def.shake); sim.cues->burst_spawn(SLAM_BURST, attack.position, 0, attack.owner); break; } case ATTACK_PUNCH: break; case ATTACK_UPPERCUT: { // The fist rises beside its fighter on the aimed side, carried up with them. Splitter's smaller copies // keep the full reach so they fan out around the fighter instead of piling up on it. float reach = UPPERCUT_REACH * attack.scale; if (fighter.stat_get(STAT_COPIES) > 1) reach /= SPLIT_SCALE; attack.position = attack.origin + vec2_polar(attack.aimAngle, reach); break; } case ATTACK_POKE: { // Its path carries the tip, never closer in than the lance's own length, so the whole lance stays out in // front of the hand and a bigger one reaches no further. float shaft = LANCE_LENGTH * attack.scale; float along = std::max(shaft, vec2_length(attack.position - attack.origin)); attack.tip = attack.origin + vec2_polar(attack.angle, along); attack.position = attack.tip - vec2_polar(attack.angle, shaft); if (attack.is_charged() && progress < PATH_OUT) sim.cues->burst_spawn(TRAIL_BURST, attack.tip, attack.angle + PI, attack.owner); break; } case ATTACK_ROCKET: sim.cues->burst_spawn(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL), attack.angle + PI, attack.owner); break; case ATTACK_TELEPORT: teleport_move(sim, attack); break; case ATTACK_DETONATE: break; case ATTACK_EXPLOSION: attack.isDead = progress >= 1; break; case ATTACK_BEAM: // A beam hits only as it's fired, then fades where it was: its first stretch stays strung from the hand // to where it struck, and each stretch it ricocheted along stays where it bounced. attack.isDead = progress >= 1; if (attack.ricochets == 0) { attack.origin = fighter.hand_get(attack.hand); attack.angle = vec2_angle(attack.tip - attack.origin); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); } break; case ATTACK_WHIP: { // A charged crack stays strung from the end of the handle in the hand to where it cracked, hitting at // once. A plain lash goes with the hand, as long as its reach along the aim (short of any wall), its handle // and links moving as lash_get says. It hits anywhere in its cone as far as it reaches until it cracks. if (attack.is_charged()) { attack.origin = fighter.hand_get(attack.hand); attack.isDead = progress >= 1; attack.angle = vec2_angle(attack.tip - attack.origin); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); break; } auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; Vec2 reach = ray_march(attack.origin, attack.aimAngle, reach_at_get(attack), isBlocked); attack.angle = vec2_angle(lash_direction_get(attack, lash_heading_get(progress))); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); attack.length = std::max(0.0f, vec2_length(reach - attack.origin) - WHIP_HANDLE); attack.tip = lash_get(attack).back(); attack.hitsLeft = progress < PATH_OUT ? attack.hitsLeft : 0; break; } case ATTACK_SPIN: { // Keeps circling its arc while attack is held, whooshing each lap. Let go, its lifetime ends with the lap. int hand = fighter.hand_slot_get(attack.hand); bool isHeld = fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key; float lap = 2 * PI * range / attack.stats.speed; float laps = std::floor(attack.life_get() / lap); if (isHeld && laps > std::floor((attack.life_get() - TICK) / lap)) sim.cues->sound_play(def.sound); if (!isHeld) attack.lifetime = std::min(attack.lifetime, (laps + 1) * lap); break; } case ATTACK_MINE: // Armed, it blinks, quicker (and beeping) as the end of its lifetime nears. attack.blink -= is_armed(attack) ? TICK : 0; if (attack.blink <= 0 && is_armed(attack)) { attack.blink = mine_blink_get(attack); if (attack.life_get() > attack.lifetime - MINE_HURRY) sim.cues->sound_play(SOUND_BEEP); } break; case ATTACK_VACUUM: vacuum_move(sim, attack); break; case ATTACK_GUST: gust_move(sim, attack); break; default: break; } // A flying shot's hitbox already spans its path this tick, and pokes, beams and lashes reach their own tips. bool isFlying = def.anchor == ANCHOR_WORLD && def.path != PATH_OWN; if (!isFlying && def.kind != ATTACK_POKE && def.kind != ATTACK_BEAM && def.kind != ATTACK_WHIP) attack.tip = attack.position; attack.isHeld = false; } // Places an anchored attack along its path from its anchor (its fighter's hand or middle) for its age, ending it once // its lifetime is up: as far out along its heading as its speed has carried it (an eased one speeding up as it goes), // out and back, or as far round its arc. One moved by its own code (a Vacuum's cone) just stays at the muzzle along its // fighter's aim, keeping whatever turn it set off with (a Splitter copy's place in the fan), its own code ending it. void Weapons::path_move(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); Fighter& fighter = sim.fighters[attack.owner]; if (def.path == PATH_OWN) { if (attack.time == 0) attack.angle = attack.aimAngle - vec2_angle(fighter.aim); attack.aimAngle = vec2_angle(fighter.aim) + attack.angle; attack.origin = def.anchor == ANCHOR_HAND ? fighter.hand_get(attack.hand) : fighter.center_get(); attack.position = attack.origin + vec2_polar(attack.aimAngle, MUZZLE_DISTANCE); attack.tip = attack.position + vec2_polar(attack.aimAngle, cone_reach_get(attack)); return; } float age = attack.life_get(); float out = PATH_OUT * attack.lifetime; float travel = def.isEased ? age * age / attack.lifetime : age; if (def.path == PATH_RETURN && age > out) travel = std::max(0.0f, out * (attack.lifetime - age) / (attack.lifetime - out)); float distance = attack.stats.speed * travel; if (def.path == PATH_ARC) { attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * (ARC_START + distance / attack.stats.range); distance = attack.stats.range; } attack.origin = def.anchor == ANCHOR_HAND ? fighter.hand_get(attack.hand) : fighter.center_get(); attack.position = attack.origin + vec2_polar(attack.angle, distance); attack.isDead = age >= attack.lifetime; } // Which way is off the surface a bolt landed on at wall, coming from where it is: a slope's own normal, or else the // side of the wall it ran into. static Vec2 surface_normal_get(Sim& sim, Vec2 from, Vec2 wall) { Vec2 normal = sim.world.normal_get(wall); if (normal.x != 0 || normal.y != 0) return vec2_normalize(normal); if (sim.world.is_blocking_at({from.x, wall.y})) return {0, wall.y > from.y ? -1.0f : 1.0f}; return {wall.x > from.x ? -1.0f : 1.0f, 0}; } // Flies a free shot one tick under its physics: turning toward its fighter's cursor (homing), speeding up toward its // top speed, slowing with drag and falling (or rising) with gravity, unless a Vacuum holds it, then moving in small // steps so it can't skip through a thin wall (its hitbox spanning the whole step so it can't skip past targets either) // until it touches one (see wall_hit). A bouncing shot moves as a small body instead, a passing one straight through, // and a crawling one on a surface crawls along it. Its lifetime up, it bursts, or fizzles out. void Weapons::flight_move(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); Vec2 start = attack.position; Fighter& fighter = sim.fighters[attack.owner]; // An orbiting shot glides to its place in its fighter's orbit and circles with it until its moment comes, then // sets off toward wherever the cursor is right then (plus its spread), its flight starting from there. if (def.isOrbiting && (attack.isGathering || attack.time < attack.duration)) { Vec2 place = vec2_polar(attack.orbitAngle, attack.orbitRadius); attack.orbit = attack.orbit + (place - attack.orbit) * std::min(1.0f, WISP_GLIDE * TICK); attack.position = fighter.center_get() + attack.orbit; attack.origin = attack.position; attack.lifeStart = attack.time; attack.tip = attack.position; return; } if (def.isOrbiting && attack.velocity.x == 0 && attack.velocity.y == 0) { attack.angle += vec2_angle(fighter.controls.cursor - attack.position); attack.velocity = vec2_polar(attack.angle, attack.stats.speed); sim.cues->sound_play(SOUND_WISP); } attack.isReturning |= def.path == PATH_RETURN && attack.time >= attack.restTime + def.hold; if (attack.isReturning && def.isTethered) attack.isDead = is_caught(fighter, attack); else if (attack.isReturning) attack.isDead = shot_return(sim, fighter, attack); else if (attack.isAttached && def.wall == WALL_CRAWL) crawl_move(sim, attack); else if (!attack.isAttached) { float speed = vec2_length(attack.velocity); Vec2 toward = fighter.controls.cursor - attack.position; if (def.homing > 0 && speed > 0 && vec2_length(toward) > HOMING_MIN) { float heading = vec2_angle(attack.velocity); float turn = std::remainder(vec2_angle(toward) - heading, 2 * PI); attack.velocity = vec2_polar(heading + std::clamp(turn, -def.homing * TICK, def.homing * TICK), speed); } if (def.accelerate > 0 && speed > 0) attack.velocity = vec2_normalize(attack.velocity) * std::min(attack.stats.speed * def.speedup, speed + def.accelerate * TICK); attack.velocity = attack.velocity * def.drag; attack.velocity.y += attack.isHeld ? 0 : def.gravity * TICK; // An arming shot (or one going out and back) slowed to a crawl comes to rest, arming (or waiting to head back) // from then. Once rested, it only drifts. bool isResting = def.isArming || def.path == PATH_RETURN; if (isResting && std::isinf(attack.restTime) && vec2_length(attack.velocity) < REST_SPEED) { attack.velocity = {}; attack.restTime = attack.time; } if (def.isAligned && (attack.velocity.x != 0 || attack.velocity.y != 0)) attack.angle = vec2_angle(attack.velocity); // A tethered shot pulls up short at the end of its chain. Vec2 hand = fighter.hand_get(attack.hand); Vec2 offset = attack.position + attack.velocity * TICK - hand; if (def.isTethered && vec2_length(offset) > attack.stats.range) attack.velocity = (hand + vec2_normalize(offset) * attack.stats.range - attack.position) * (1 / TICK); Vec2 next = attack.position + attack.velocity * TICK; Vec2 wall; WallCheck isWall = def.isPassingPlatforms ? &World::is_solid_at : &World::is_blocking_at; if (def.wall == WALL_BOUNCE) body_bounce(sim, attack); else if (def.wall == WALL_PASS && attack.bounces <= 0) attack.position = next; else if (is_wall_reached(sim, attack, next, wall, isWall)) wall_hit(sim, attack, wall, attack.isHeld); } attack.tip = start; bool isOver = !attack.isDead && attack.life_get() >= attack.lifetime; if (isOver && def.isBursting) attack_explode(sim, attack); else if (isOver) { attack.isDead = true; if (def.wall == WALL_END) sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } } // A flying shot touching a wall: held in a Vacuum, it glances off. Otherwise Ricochet bounces it while it has bounces // left, and then its wall response decides: it ends (with a thud), bursts, sticks and lingers (harmless, as a foothold // for its fighter, though a breakable block just takes the hit), settles against the surface to crawl along it the way // it was going, or stops dead. void Weapons::wall_hit(Sim& sim, Attack& attack, Vec2 wall, bool isHeld) { const AttackDef& def = *attack.def(); if (isHeld) { attack.velocity = reflect_get(sim, attack.position, wall, attack.velocity); return; } if (attack.bounces > 0) { ricochet(sim, attack, wall); return; } bool isBlock = sim.world.tile_at(wall) == TILE_BLOCK; switch (def.wall) { case WALL_STICK: if (!isBlock) { attack.isAttached = true; attack.hitsLeft = 0; attack.velocity = {}; attack.lifetime = attack.life_get() + ARROW_LINGER; impact_play(sim, def.kind); sim.cues->burst_spawn(IMPACT_BURST, wall, 0, attack.owner); break; } [[fallthrough]]; case WALL_END: attack.isDead = true; impact_play(sim, def.kind); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); break; case WALL_BURST: attack.position = wall; attack_explode(sim, attack); break; case WALL_CRAWL: { Vec2 normal = surface_normal_get(sim, attack.position, wall); Vec2 along = {-normal.y, normal.x}; along = vec2_dot(attack.velocity, along) < 0 ? along * -1 : along; for (float reach = 0; reach < CRAWL_SNAP && !sim.world.is_blocking_at(attack.position - normal); reach++) attack.position = attack.position - normal; attack.side = along.x * -normal.y - along.y * -normal.x > 0 ? 1 : -1; attack.velocity = along * attack.stats.speed; attack.isAttached = true; break; } case WALL_LATCH: attack.position = wall; hook_attach(sim, attack); break; case WALL_RETURN: attack.isReturning = true; sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); [[fallthrough]]; case WALL_STOP: case WALL_BOUNCE: case WALL_PASS: attack.velocity = {}; impact_play(sim, def.kind); break; } } // A crawling shot on a surface: each step it turns as far toward its surface as it can still move, from straight into // it round to straight away from it, so it hugs floors, walls, ceilings, platforms, corners and edges alike, at its // speed. Boxed in, it turns back. One left inside a wall (fired from in one) fizzles out. void Weapons::crawl_move(Sim& sim, Attack& attack) { float speed = attack.stats.speed; int steps = std::max(1, (int)(speed * TICK / CRAWL_STEP)); for (int step = 0; step < steps; step++) { float heading = vec2_angle(attack.velocity); bool isMoved = false; for (int turn = 0; turn <= CRAWL_TURNS && !isMoved; turn++) { float angle = heading + attack.side * (PI / 2 - turn * PI / CRAWL_TURNS); Vec2 ahead = attack.position + vec2_polar(angle, CRAWL_STEP); if (sim.world.is_blocking_at(ahead)) continue; attack.position = ahead; attack.velocity = vec2_polar(angle, speed); isMoved = true; } if (!isMoved) attack.velocity = attack.velocity * -1; } attack.angle = vec2_angle(attack.velocity); attack.isDead |= sim.world.is_blocking_at(attack.position); } // A bouncing shot moves as a small box body, staying on the ground across ramps the way a fighter does (stepping over // a ramp's crest and down its far side). Off surfaces it bounces back (with a knock), losing some speed, slanting off // ramps. On the ground it rolls downhill under its gravity, and on the flat to a stop, turning as it goes. A bounce // Ricochet counts starts its flight afresh, as a ricochet does any shot's. void Weapons::body_bounce(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); float size = def.body * attack.scale; Body body = { .box = {attack.position.x - size / 2, attack.position.y - size / 2, size, size}, .velocity = attack.velocity, .slope = attack.slope, .isGrounded = attack.isGrounded, }; if (body.isGrounded) body.velocity.x += def.gravity * body.slope / (1 + body.slope * body.slope) * TICK; Vec2 before = body.velocity; sim.world.body_move(body); bool isBounced = false; bool isRamp = body.normal.x != 0 || body.normal.y != 0; float into = vec2_dot(before, body.normal); if (isRamp && into < -SHOT_BOUNCE_MIN) { body.velocity = before - body.normal * (into * (1 + def.bounce)); body.isGrounded = false; isBounced = true; } if (!isRamp && body.velocity.x == 0 && std::abs(before.x) > SHOT_BOUNCE_MIN) { body.velocity.x = -before.x * def.bounce; isBounced = true; } if (!isRamp && body.velocity.y == 0 && std::abs(before.y) > SHOT_BOUNCE_MIN) { body.velocity.y = -before.y * def.bounce; body.isGrounded = false; isBounced = true; } if (isBounced && attack.bounces > 0) { ricochet_count(sim, attack); attack.lifeStart = attack.time; } else if (isBounced) impact_play(sim, def.kind); if (body.isGrounded && body.slope == 0) body.velocity.x = std::clamp(0.0f, body.velocity.x - def.friction * TICK, body.velocity.x + def.friction * TICK); attack.position = {body.box.x + size / 2, body.box.y + size / 2}; attack.velocity = body.velocity; attack.isGrounded = body.isGrounded; attack.slope = body.slope; attack.angle += body.velocity.x * ROLL_SPIN * TICK / std::max(1.0f, size * PI); } // Keeps this tick's pose so drawing can blend from it. New attacks remember their first pose // so they don't sweep in from where they were spawned. void Weapons::attack_remember(Attack& attack) { attack.originPrevious = attack.origin; attack.positionPrevious = attack.position; attack.tipPrevious = attack.tip; attack.anglePrevious = attack.angle; attack.timePrevious = attack.time; } // Moves every attack alpha of the way through the last tick. Only for drawing. void Weapons::blend(float alpha) { for (Attack& attack : attacks) { attack.origin = vec2_lerp(attack.originPrevious, attack.origin, alpha); attack.position = vec2_lerp(attack.positionPrevious, attack.position, alpha); attack.tip = vec2_lerp(attack.tipPrevious, attack.tip, alpha); attack.angle = angle_lerp(attack.anglePrevious, attack.angle, alpha); attack.time = math_lerp(attack.timePrevious, attack.time, alpha); } } void Weapons::attack_hit(Sim& sim, Attack& attack, int target, Vec2 point) { const AttackDef& def = *attack.def(); if (def.kind == ATTACK_ROCKET || def.kind == ATTACK_FIREBALL) { attack_explode(sim, attack); return; } impact_play(sim, def.kind); // Explosions deal full damage at their center, down to a fraction at the edge, by the target's nearest point. A // Vacuum's suck bites harder the closer to its muzzle. float taper = is_cone(def) ? cone_strength_get(attack, point) : 1; taper *= def.kind == ATTACK_GRENADE ? GRENADE_CONTACT_SHARE : 1; // The double donk: a marking attack (a grenade's clonk) marks whoever it hits, and a blast from the same owner's // same weapon landing on them soon after hits three times as hard, with a sound of its own. bool isFighter = target < MAX_FIGHTERS; float& donkWindow = isFighter ? sim.fighters[target].donkWindow : sim.world.dummies[target - MAX_FIGHTERS].donkWindow; int& donkOwner = isFighter ? sim.fighters[target].donkOwner : sim.world.dummies[target - MAX_FIGHTERS].donkOwner; int& donkWeapon = isFighter ? sim.fighters[target].donkWeapon : sim.world.dummies[target - MAX_FIGHTERS].donkWeapon; bool isMarked = donkWindow > 0 && donkOwner == attack.owner && donkWeapon == attack.weapon; if (def.kind == ATTACK_EXPLOSION && isMarked) { taper *= DONK_DAMAGE; donkWindow = 0; attack.isDonked = true; attack.duration = std::max(attack.duration, DONK_FLASH); sim.cues->sound_play(SOUND_DONK); } if (def.isMarking) { donkWindow = DONK_WINDOW; donkOwner = attack.owner; donkWeapon = attack.weapon; } if (def.kind == ATTACK_EXPLOSION) { Rect box = target_box(sim, target); Vec2 nearest = {std::clamp(attack.position.x, box.x, box.x + box.w), std::clamp(attack.position.y, box.y, box.y + box.h)}; float edge = std::min(1.0f, vec2_length(nearest - attack.position) / attack.stats.range); taper *= 1 - edge * (1 - SPLASH_EDGE); } int damage = std::max(1.0f, std::round(attack.stats.damage * multiplier_get(attack, point) * taper)); SoundId sound = hit_sound_get(attack.weapon >= 0 ? WEAPON_DEFS[attack.weapon].hit : HIT_BLUNT, damage); attack.hitsLeft--; attack.hitCooldown = HIT_INTERVAL; // A grenade clonks everyone it passes once, one right after another. if (def.kind == ATTACK_GRENADE) { attack.struck |= target < STRUCK_MAX ? 1u << target : 0; attack.hitCooldown = 0; } attack.isDead |= def.isShot && attack.hitsLeft <= 0; if (def.wall == WALL_LATCH) hook_attach(sim, attack); // Uppercuts knock the way they rise. Rect box = target_box(sim, target); Vec2 source = def.kind == ATTACK_EXPLOSION ? attack.position : sim.fighters[attack.owner].center_get(); if (def.kind == ATTACK_UPPERCUT) source = Vec2{box.x + box.w / 2, box.y + box.h / 2} - vec2_polar(attack.angle, 1); damage_deal(sim, { .point = point, .source = source, .hitstop = damage >= HITSTOP_MIN_DAMAGE ? std::min(HITSTOP_MAX, damage * HITSTOP_PER_DAMAGE) : 0, .knock = attack.isGentle ? 0 : def.knock, .amount = damage, .owner = attack.owner, .target = target, .sound = sound, }); } // Tips a dummy the way a sudden push goes (a block, which never leans, ignores it), by its sideways part: a dummy // stands on its stand, so only its lean feels the push. static void dummy_push(Dummy& dummy, Vec2 push) { if (dummy.tile < 0) dummy.wobbleSpeed += push.x * WOBBLE_PER_SPEED; } // Hurts a target with all the feedback: sparks, a number, shake that grows with the damage and a sound. The attacker // freezes in hitstop, its Boxing Glove adds to the knock, and its Embiggener to the damage. // Dummies flash and wobble, blocks crack and break, and fighters take the damage themselves. In a race, fighters only // get knocked about, so there's no number. void Weapons::damage_deal(Sim& sim, Damage damage) { Fighter& attacker = sim.fighters[damage.owner]; Rect box = target_box(sim, damage.target); damage.amount = std::max(1, (int)std::round(damage.amount * attacker.stat_get(STAT_DAMAGE))); float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1; attacker.hitstop_add(damage.hitstop); damage.knock *= attacker.stat_get(STAT_KNOCKBACK); bool isCounted = damage.target != damage.owner && !(damage.target < MAX_FIGHTERS && sim.match.is_racing()); attacker.damageDealt += isCounted ? damage.amount : 0; if (damage.target < MAX_FIGHTERS) sim.fighters[damage.target].damage_take(sim, damage); else { Dummy& dummy = sim.world.dummies[damage.target - MAX_FIGHTERS]; dummy.flash = 1; dummy_push(dummy, knock_get(damage, {box.x + box.w / 2, box.y + box.h / 2})); dummy.health -= damage.amount; if (dummy.tile >= 0 && dummy.health <= 0) sim.world.block_break(sim, dummy, damage.owner); } Burst burst = HIT_BURST; burst.count += HIT_PARTICLES_PER_DAMAGE * damage.amount; sim.cues->burst_spawn(burst, damage.point, side > 0 ? 0 : PI, damage.owner); sim.cues->shake_add(damage.amount * SHAKE_PER_DAMAGE); sim.cues->sound_play(damage.sound); if (damage.target < MAX_FIGHTERS && sim.match.is_racing()) return; sim.cues->number_spawn(damage); } // Pebble Helmet: touching another fighter or a dummy (not a block) hurts it, by the level's damage times the usual // bonus for the fighter's speed, at most once every CONTACT_INTERVAL. void Weapons::contact_update(Sim& sim, Fighter& fighter) { float base = fighter.stat_get(STAT_CONTACT_DAMAGE); fighter.contactCooldown -= TICK; if (base <= 0 || !fighter.isAlive || fighter.hitstop > 0 || fighter.contactCooldown > 0) return; Rect box = fighter.body.box; int targets = MAX_FIGHTERS + sim.world.dummies.size(); for (int target = 0; target < targets; target++) { bool isFighter = target < MAX_FIGHTERS; if (isFighter && (target >= sim.fighterCount || target == fighter.index || !sim.fighters[target].isAlive || !is_hostile(sim, fighter.index, target))) continue; if (!isFighter && sim.world.dummies[target - MAX_FIGHTERS].tile >= 0) continue; Rect other = target_box(sim, target); if (!is_rect_overlapping(box, other)) continue; float speed = vec2_length(fighter.body.velocity); float bonus = 1 + (SPEED_DAMAGE + fighter.stat_get(STAT_SPEED_DAMAGE)) * speed / SPEED_UNIT; int damage = std::max(1.0f, std::round(base * bonus)); Vec2 point = {(std::max(box.x, other.x) + std::min(box.x + box.w, other.x + other.w)) / 2, (std::max(box.y, other.y) + std::min(box.y + box.h, other.y + other.h)) / 2}; damage_deal( sim, { .point = point, .source = fighter.center_get(), .hitstop = damage >= HITSTOP_MIN_DAMAGE ? std::min(HITSTOP_MAX, damage * HITSTOP_PER_DAMAGE) : 0, .knock = CONTACT_KNOCK, .amount = damage, .owner = fighter.index, .target = target, .sound = hit_sound_get(HIT_BLUNT, damage), }); fighter.contactCooldown = CONTACT_INTERVAL; return; } } // Every fighter's hovering wisps share one orbit around it: the oldest fill the inner ring, as many as fit round it // WISP_SPACING apart, and the rest the rings beyond, so a crowd of wisps makes concentric rings. Each ring spreads its // wisps evenly and spins the other way to the one inside it. void Weapons::orbits_update(Sim& sim) { orbitClock = std::fmod(orbitClock + TICK, 2 * PI / WISP_SPIN); std::vector hovering; for (int i = 0; i < sim.fighterCount; i++) { float scale = sim.fighters[i].scale; hovering.clear(); for (Attack& attack : attacks) if (attack.owner == i && attack.def()->isOrbiting && !attack.isDead && (attack.isGathering || attack.time < attack.duration)) hovering.push_back(&attack); int placed = 0; for (int ring = 0; placed < (int)hovering.size(); ring++) { float radius = (WISP_RING + ring * WISP_RING_GAP) * scale; int count = std::min(std::max(1, (int)(2 * PI * radius / (WISP_SPACING * scale))), (int)hovering.size() - placed); float spin = (ring % 2 == 0 ? 1 : -1) * WISP_SPIN * orbitClock; for (int k = 0; k < count; k++) { hovering[placed + k]->orbitRadius = radius; hovering[placed + k]->orbitAngle = -PI / 2 + spin + 2 * PI * k / count; } placed += count; } } } // Letting go of attack sends every wisp gathered in the fighter's orbit at the cursor, oldest first, one after // another, all of them within WISP_RELEASE_SPAN however many there are. void Weapons::orbits_release(Fighter& fighter) { int count = 0; for (Attack& attack : attacks) count += attack.owner == fighter.index && attack.isGathering && !attack.isDead; float step = std::min(WISP_LAUNCH_STAGGER, WISP_RELEASE_SPAN / std::max(1, count)); int launched = 0; for (Attack& attack : attacks) { if (attack.owner != fighter.index || !attack.isGathering || attack.isDead) continue; attack.isGathering = false; attack.duration = attack.time + launched++ * step; } } // Grimoire teleport: sparkles round the fighter while it gathers, then moves them to the spot marked when it was cast // (backed off again if their box no longer fits), keeping all their momentum, with a blast there that hurts without // knocking anyone about. Echoes only make the blast. void Weapons::teleport_move(Sim& sim, Attack& attack) { Fighter& fighter = sim.fighters[attack.owner]; attack.radius = 0; attack.hitsLeft = 0; if (attack.time < attack.duration) { if (!attack.isCopy) sim.cues->burst_spawn(WARP_BURST, fighter.center_get(), 0, attack.owner); return; } attack.isDead = true; Vec2 spot = attack.position; if (!attack.isCopy && attack.hand == 0 && fighter.isAlive) { sim.cues->burst_spawn(PORTAL_BURST, fighter.center_get(), 0, attack.owner); spot = teleport_spot(sim, fighter, attack.position, vec2_length(attack.position - fighter.center_get())); fighter.teleport_to(spot); sim.cues->shake_add(attack.def()->shake); } blast_spawn(sim, { .position = spot, .area = attack.stats.area * attack.scale, .damage = attack.stats.damage, .owner = attack.owner, .sound = SOUND_TELEPORT, .weapon = attack.weapon, .isGentle = true, .isSecondary = attack.isSecondary, }); } // Tesla Coil: a web of lightning between every weapon the fighter has out in the world (anything but explosions). // Each piece arcs to its nearest few neighbors within reach, so everything nearby is connected without the // links growing out of hand for rapid-fire weapons. Each coil makes the links thicker. std::vector Weapons::chain_get(Sim& sim, Fighter& fighter) { std::vector links; if (!fighter.is_trinket_equipped(TRINKET_TESLA)) return links; float range = fighter.stat_get(STAT_ARC_RANGE); float radius = TESLA_RADIUS * fighter.trinket_count(TRINKET_TESLA); std::vector pieces; for (Attack& attack : attacks) if (attack.owner == fighter.index && !attack.isDead && attack.def()->kind != ATTACK_EXPLOSION && !is_cone(*attack.def())) pieces.push_back(attack.position); int count = pieces.size(); std::vector isLinked(count * count, false); std::vector> near; for (int i = 0; i < count; i++) { near.clear(); for (int j = 0; j < count; j++) { float distance = vec2_length(pieces[j] - pieces[i]); if (j != i && distance <= range) near.push_back({distance, j}); } int reach = std::min((int)near.size(), TESLA_LINKS); std::partial_sort(near.begin(), near.begin() + reach, near.end()); for (int k = 0; k < reach; k++) { int j = near[k].second; if (isLinked[std::min(i, j) * count + std::max(i, j)]) continue; isLinked[std::min(i, j) * count + std::max(i, j)] = true; links.push_back({pieces[i], pieces[j], radius}); } } return links; } // Shocks every target a fighter's links touch, at most once per interval each. void Weapons::tesla_update(Sim& sim) { for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.fighters[i]; float damage = fighter.stat_get(STAT_SHOCK_DAMAGE); for (Capsule& link : chain_get(sim, fighter)) { for (int target = 0; target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++) { Vec2 point; if (!is_target(sim, target, i) || !is_capsule_in_rect(link, target_box(sim, target), point)) continue; float& shock = target < MAX_FIGHTERS ? sim.fighters[target].shockCooldown : sim.world.dummies[target - MAX_FIGHTERS].shockCooldown; if (shock > 0) continue; shock = TESLA_INTERVAL; damage_deal(sim, {.point = point, .source = fighter.center_get(), .knock = 0, .amount = (int)damage, .owner = i, .target = target, .sound = hit_sound_get(HIT_ELECTRIC, (int)damage)}); } } } } // The paths the held weapon's attack would take if fired now (one per Splitter copy), out to the first wall it can't // ricochet off (Ricochet bounces it off as many as the shot would) or the attack's reach: curving under gravity for // arrows, straight for other shots, throws, beams, lashes, mines and hooks, and a teleport's line to where it lands. // Anything that explodes rings the blast it would make where the path ends (its reach counts from the hand). Melee, // flames and flails have none, as their paths aren't lines. std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) { std::vector paths; int key = fighter.weapon_key(fighter.slot); if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, 0))) return paths; const WeaponDef& weapon = WEAPON_DEFS[fighter.weaponLoadout[key]]; const LevelDef& level = fighter.level_get(key); AttackVariant variant = variant_get(fighter.charge); const AttackDef& def = variant == VARIANT_CHARGED ? weapon.charged : weapon.normal; AttackKind kind = def.kind; AttackStats stats = variant == VARIANT_CHARGED ? level.charged : level.normal; if (weapon.mode == FIRE_DRAWN) stats = stats_blend(level, fighter.charge); if (kind == ATTACK_TELEPORT) { Vec2 spot = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range); Vec2 from = fighter.center_get(); Path path = {.area = stats.area * def.scale * fighter.stat_get(STAT_ATTACK_SIZE)}; float length = vec2_length(spot - from); for (float traveled = 0; traveled < length; traveled += TRAJECTORY_STEP) path.steps.push_back(from + (spot - from) * (traveled / length)); path.steps.push_back(spot); paths.push_back(path); return paths; } // A Vacuum's cone (one per Splitter copy, fanned), from the muzzle out to its reach. int splits = fighter.stat_get(STAT_COPIES); if (is_cone(def)) { Vec2 muzzle = fighter.hand_get() + fighter.aim * MUZZLE_DISTANCE; float reach = stats.range * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1); for (int split = 0; split < splits; split++) { float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.stat_get(STAT_SPREAD_CUT)) : 0; Vec2 tip = muzzle + vec2_polar(vec2_angle(fighter.aim) + offset, reach); paths.push_back({.steps = {muzzle, tip}, .cone = VACUUM_CONE}); } return paths; } bool isArrow = kind == ATTACK_ARROW; bool isStraight = kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_BEAM || kind == ATTACK_WHIP || kind == ATTACK_MINE || kind == ATTACK_BOOMERANG || (kind == ATTACK_HOOK && variant != VARIANT_CHARGED); if (!isArrow && !isStraight) return paths; // Each Splitter copy gets its own path, fanned like the copies. Straight paths step along the aim one unit at a // time. Arrows fly at their real speed so gravity bends them. float area = stats.area * def.scale * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1); float reach = std::min(stats.range, TRAJECTORY_REACH); for (int split = 0; split < splits; split++) { float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.stat_get(STAT_SPREAD_CUT)) : 0; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim); Vec2 velocity = isArrow ? aim * speed : aim; Attack probe = {.origin = fighter.hand_get(), .angle = vec2_angle(aim), .weapon = fighter.weaponLoadout[key], .isLeft = fighter.is_left()}; Vec2 point = muzzle_get(probe); int bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0; Path path; bool isEnded = stats.range <= TRAJECTORY_REACH; for (float traveled = area > 0 ? MUZZLE_DISTANCE : 0; traveled < reach; traveled += TRAJECTORY_STEP) { float time = TRAJECTORY_STEP / std::max(1.0f, vec2_length(velocity)); Vec2 next = point + velocity * time; if (sim.world.is_blocking_at(next)) { isEnded |= bounces <= 0; if (bounces-- <= 0) break; velocity = reflect_get(sim, point, next, velocity); continue; } velocity.y += def.gravity * time; path.steps.push_back(point); point = next; } path.steps.push_back(point); path.area = isEnded ? area : 0; paths.push_back(path); } return paths; } // Latches the hook where it is, holding until let go, and sets the rope to its fighter's current distance. Charged // hooks start pulling. void Weapons::hook_attach(Sim& sim, Attack& attack) { Fighter& fighter = sim.fighters[attack.owner]; attack.isAttached = true; attack.lifetime = LIFETIME_INFINITE; attack.isPulling = attack.is_charged(); attack.hitsLeft = 0; attack.length = std::min(vec2_length(fighter.center_get() - attack.position), attack.stats.range); sim.cues->sound_play(SOUND_LATCH); sim.cues->shake_add(attack.def()->shake); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } // An instant attack reaches its full range at once along its aim, stopping at the first wall (or, for a latching one, // the first target), where a latching one latches. Each Ricochet bounce marches its full range again as a segment of // its own, its falloff starting afresh. A bursting one blows up at its tip, without a push. void Weapons::instant_launch(Sim& sim, Attack& piece) { const AttackDef& def = *piece.def(); bool isLatching = def.wall == WALL_LATCH; auto isBlocked = [&](Vec2 point) { for (int target = 0; isLatching && target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++) if (is_target(sim, target, piece.owner) && is_point_in_rect(point, target_box(sim, target))) return true; return sim.world.is_blocking_at(point); }; Vec2 start = def.kind == ATTACK_WHIP ? piece.origin : piece.position; piece.velocity = {}; piece.tip = ray_march(start, piece.angle, piece.stats.range, isBlocked); if (isLatching) { piece.position = piece.tip; if (sim.world.is_blocking_at(piece.position)) hook_attach(sim, piece); return; } sim.cues->burst_spawn(SPARK_BURST, piece.tip, piece.angle + PI, piece.owner); Attack segment = piece; for (int i = 0; i < piece.bounces; i++) { Vec2 before = segment.tip - vec2_polar(segment.angle, RAY_STEP); segment.angle = vec2_angle(reflect_get(sim, before, segment.tip, vec2_polar(segment.angle, 1))); segment.position = before; segment.tip = ray_march(segment.position, segment.angle, piece.stats.range, isBlocked); segment.ricochets++; attacks.push_back(segment); sim.cues->burst_spawn(SPARK_BURST, segment.tip, segment.angle + PI, piece.owner); } if (def.isBursting) blast_spawn(sim, { .position = piece.tip, .area = piece.stats.area * piece.scale / piece.stats.scale, .damage = piece.stats.damage, .owner = piece.owner, .sound = SOUND_NONE, // the attack's own sound already carries it .weapon = piece.weapon, .isSecondary = piece.isSecondary, }); } // Pulls a whip's wielder toward a wall its lash (reaching as far as it reaches when fired) meets short of full length. void Weapons::whip_pull(Sim& sim, Attack& piece) { Fighter& fighter = sim.fighters[piece.owner]; float reach = reach_at_get(piece); Vec2 end = ray_march(piece.origin, piece.angle, reach, [&](Vec2 point) { return sim.world.is_blocking_at(point); }); Vec2 lash = end - piece.origin; if (!piece.isCopy && vec2_length(lash) < reach - RAY_STEP) fighter.impulse_add(vec2_normalize(lash) * WHIP_PULL * fighter.stat_get(STAT_MOVEMENT)); } // Bursts a rocket, mine or fireball into a pushing explosion, keeping the damage raised by any ricochets and cut by // how far it flew. The // blast grows with trinkets but not with the attack's own size, so a big fireball keeps its listed area. void Weapons::attack_explode(Sim& sim, Attack& attack) { attack.isDead = true; attack.hitsLeft = 0; blast_spawn(sim, { .position = attack.position, .area = blast_area_get(attack), .bonus = strength_get(attack, attack.position), .damage = attack.stats.damage * multiplier_get(attack, attack.position), .owner = attack.owner, .sound = SOUND_EXPLOSION, .weapon = attack.weapon, .isPushing = true, .isSecondary = attack.isSecondary, }); } // Queues an explosion that hits everything inside it next tick, weaker toward its edge. Pushing blasts fling every // fighter away from the center, harder the closer they are. void Weapons::blast_spawn(Sim& sim, Blast blast) { float reach = blast.area * KNOCKBACK_REACH; float boxing = blast.owner >= 0 ? sim.fighters[blast.owner].stat_get(STAT_KNOCKBACK) : 1; for (int i = 0; i < sim.fighterCount; i++) { Fighter& fighter = sim.fighters[i]; Vec2 away = fighter.center_get() - blast.position; float distance = vec2_length(away); // Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback (which the // blast's owner's Boxing Glove adds to). float absorb = i == blast.owner ? fighter.stat_get(STAT_MOVEMENT) : fighter.stat_get(STAT_KNOCKBACK_TAKEN) * boxing; if (!blast.isPushing || !fighter.isAlive || distance >= reach) continue; Vec2 push = vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * (1 - distance / reach) * absorb; fighter.impulse_add(push); } for (Dummy& dummy : sim.world.dummies) { Vec2 away = Vec2{dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h / 2} - blast.position; float distance = vec2_length(away); if (blast.isPushing && distance < reach) dummy_push(dummy, vec2_normalize(away) * KNOCKBACK_SPEED * (1 - distance / reach) * boxing); } Attack attack = { .stats = {.damage = blast.damage, .range = blast.area}, .origin = blast.position, .position = blast.position, .tip = blast.position, .bonus = blast.bonus, .duration = EXPLOSION_DEF.duration, .hitsLeft = 1, .owner = blast.owner, .weapon = blast.weapon, .isExplosion = true, .isGentle = blast.isGentle, .isSecondary = blast.isSecondary, }; attack_remember(attack); pending.push_back(attack); sim.cues->sound_play(blast.sound); sim.cues->shake_add(EXPLOSION_DEF.shake); sim.cues->burst_spawn(BLAST_BURST, blast.position, 0, blast.owner); } // The whip lashing or hook flying out from this hand, which leaves only the weapon's base in it. Attack* Weapons::tether_get(Fighter& fighter, int key, int hand) { for (Attack& attack : attacks) { AttackKind kind = attack.def()->kind; if ((kind == ATTACK_WHIP || kind == ATTACK_HOOK) && attack.owner == fighter.index && attack.key == key && attack.hand == hand && !attack.isCopy && !attack.isDead) return &attack; } return nullptr; } // Adds the fighter's own attacks it can stand on to the world for its next move: charged boomerangs in flight as // obstacles it bumps into, and stuck arrows (until they start fading) as ledges it can only land on from above (and // drop through). void Weapons::solids_add(Fighter& fighter, World& world) { for (Attack& attack : attacks) { if (attack.owner != fighter.index || attack.isDead) continue; AttackKind kind = attack.def()->kind; if (kind == ATTACK_BOOMERANG && attack.is_charged()) { float size = attack.radius; world.obstacles.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2}); } // A stuck arrow's ledge is the box around its hit shape. if (kind == ATTACK_ARROW && attack.isAttached && attack.life_get() < attack.lifetime - ARROW_FADE) { Vec2 low = {std::min(attack.position.x, attack.tip.x), std::min(attack.position.y, attack.tip.y)}; Vec2 high = {std::max(attack.position.x, attack.tip.x), std::max(attack.position.y, attack.tip.y)}; float radius = attack.radius; world.ledges.push_back( {low.x - radius, low.y - radius, high.x - low.x + radius * 2, high.y - low.y + radius * 2}); } } } // Whether the fighter has a flail spinning around it. bool Weapons::is_spinning(Fighter& fighter) { for (Attack& attack : attacks) if (attack.owner == fighter.index && attack.def()->kind == ATTACK_SPIN && !attack.isDead) return true; return false; } // The latched grapple hook the fighter is hanging from, if any. // The attack a continuous weapon (see FireMode) in a weapon slot keeps going from a hand, or none. Attack* Weapons::continuous_get(Fighter& fighter, int key, int hand) { for (Attack& attack : attacks) if (attack.owner == fighter.index && attack.key == key && attack.hand == hand && !attack.isCopy && !attack.isDead && attack.def()->kind == ATTACK_VACUUM) return &attack; return nullptr; } Attack* Weapons::hook_get(Fighter& fighter) { for (Attack& attack : attacks) if (attack.owner == fighter.index && attack.def()->kind == ATTACK_HOOK && attack.isAttached && !attack.isDead) return &attack; return nullptr; } // Removes every attack of one kind the fighter owns at once, like all grapple hooks so Splitter copies drop together. void Weapons::attacks_release(Fighter& fighter, AttackKind kind) { for (Attack& attack : attacks) attack.isDead |= attack.owner == fighter.index && attack.def()->kind == kind; } // A fresh press calls thrown weapons back: Charge recalls boomerangs once all are out, Attack or Charge // reels in a flail ball. Reports whether it did. bool Weapons::is_recalled(Sim& sim, Fighter& fighter) { int key = fighter.weapon_key(fighter.slot); if (key < 0) return false; bool isLeft = fighter.is_pressed(CONTROL_ATTACK); bool isRight = fighter.is_pressed(CONTROL_CHARGE); bool isBoomerang = WEAPON_DEFS[fighter.weaponLoadout[key]].normal.kind == ATTACK_BOOMERANG && isRight && is_busy(fighter, key); bool isFlail = false; for (Attack& attack : attacks) isFlail |= attack.def()->kind == ATTACK_FLAIL && attack.owner == fighter.index && attack.key == key && !attack.isReturning; isFlail &= isLeft || isRight; if (!isBoomerang && !isFlail) return false; for (Attack& attack : attacks) attack.isReturning |= attack.owner == fighter.index && attack.key == key; return true; } float mine_blink_get(Attack& attack) { float left = std::clamp((attack.lifetime - attack.life_get()) / MINE_HURRY, 0.0f, 1.0f); return MINE_BLINK_MIN + (MINE_BLINK - MINE_BLINK_MIN) * left; } // Whether a Vacuum moves a shot right now: one whose attack drifts, as long as it's loose going its own way (a wisp // once sent, a flail ball not being reeled in, a grapple out) or stuck to a surface (which it comes loose from). static bool is_drifting(Attack& shot) { const AttackDef& def = *shot.def(); bool isOrbiting = def.isOrbiting && shot.velocity.x == 0 && shot.velocity.y == 0; bool isWhiffing = def.isInstant && !shot.isAttached; return def.isDrifting && !shot.isReturning && !isOrbiting && !isWhiffing; } // Gives a shot back a share of the flight it set off with, never more than it's used, which winds back its damage // falloff with it (a fuse too). One that flies on forever gets back a share of its fade. A hook, whose falloff goes by // distance, gets back a share of its range. static void shot_restore(Attack& shot, float share) { AttackKind kind = shot.def()->kind; float lifetime = std::isinf(shot.lifetime) ? shot.fade : shot.lifetime; shot.lifeStart = std::max(shot.lifeStart, std::min(shot.time, shot.lifeStart + lifetime * share)); if (kind != ATTACK_HOOK) return; Vec2 back = shot.position - shot.origin; shot.origin = shot.origin + vec2_normalize(back) * std::min(shot.stats.range * share, vec2_length(back)); } // Moves every shot in an attack's cone (from its position along its aim, out to its range), anyone's: a blow turns each // to fly straight out of the cone from its point (along the aim, give or take where in the cone it is), at its own // speed plus the blow's, and pull speeds each toward the point. A shot stuck to a surface comes loose first (a // grapple's hook too), popping out toward the point at the cone's own speed when pulled, and glancing off the surface // if it would head straight back in. A claiming attack makes other fighters' shots its owner's, burst into its color // (all but those tied to their fighter's weapon: on a rope or chain, waiting to be set off, or the weapon itself). The // owner's own shots then get back restore's share of what they set off with (see shot_restore). void Weapons::shots_drift(Sim& sim, Attack& source, float blow, float pull, float restore) { for (Attack& shot : attacks) { AttackKind kind = shot.def()->kind; if (&shot == &source || shot.isExplosion || !is_drifting(shot) || !is_in_cone(source, shot.position)) continue; const AttackDef& def = *shot.def(); bool isTied = def.isTethered || def.isArming || def.isWielded; if (source.def()->isClaiming && shot.owner != source.owner && !isTied) { shot.ownerPrevious = shot.owner; shot.owner = source.owner; shot.claimTime = shot.time; shot.key = -1; shot.isSecondary = false; shot.struck = 0; shot.hitCooldown = 0; sim.cues->burst_spawn(SPARK_BURST, shot.position, source.aimAngle, source.owner); } Vec2 toward = vec2_normalize(source.position - shot.position); bool isLoosened = shot.isAttached; // Boxing Glove moves shots harder, as it knocks fighters harder. float boxing = sim.fighters[source.owner].stat_get(STAT_KNOCKBACK); if (isLoosened) { shot.isAttached = false; shot.hitsLeft = kind == ATTACK_ARROW ? shot.def()->hits : shot.hitsLeft; shot.velocity = pull > 0 ? toward * source.stats.speed * boxing : Vec2{}; } shot.isHeld |= pull > 0; shot.velocity = shot.velocity + toward * pull * boxing; if (blow > 0) shot.velocity = toward * -(vec2_length(shot.velocity) + blow * boxing); Vec2 next = shot.position + shot.velocity * TICK; if (isLoosened && sim.world.is_blocking_at(next)) shot.velocity = reflect_get(sim, shot.position, next, shot.velocity); if (shot.def()->isAligned) shot.angle = vec2_angle(shot.velocity); if (restore > 0 && shot.owner == source.owner) shot_restore(shot, restore); } } // The Vacuum's suck: a long, narrow cone from the muzzle along the aim, following the hand, kept going for as long as // it's held (the attack control down with it still in that hand). It spins up to its full reach, and let go, it winds // back down and goes (taken up again on the way down, it spins back up). Fighters in it (and anyone's shots) are // pulled toward the muzzle, topped up to its pull speed rather than stacking. Once every interval (its cooldown) // it nibbles at every target whose middle is in the cone. (The air streaming in along it is drawn from the cone, see // Scene::suck_draw.) void Weapons::vacuum_move(Sim& sim, Attack& attack) { Fighter& fighter = sim.fighters[attack.owner]; int hand = fighter.hand_slot_get(attack.hand); bool isHeld = fighter.isAlive && fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key; attack.isReturning = !isHeld; attack.length = std::clamp(attack.length + (isHeld ? TICK / VACUUM_SPIN_UP : -TICK / VACUUM_SPIN_DOWN), 0.0f, 1.0f); attack.isDead = !isHeld && attack.length <= 0; for (int i = 0; i < sim.fighterCount; i++) { Fighter& target = sim.fighters[i]; Vec2 center = target.center_get(); if (!is_target(sim, i, attack.owner) || !is_in_cone(attack, center)) continue; // Boxing Glove pulls harder, as it knocks harder. float boxing = fighter.stat_get(STAT_KNOCKBACK); Vec2 toward = vec2_normalize(attack.position - center); float room = std::max(0.0f, attack.stats.speed * boxing - vec2_dot(target.body.velocity, toward)); float pull = std::min(room, VACUUM_PULL_RATE * boxing * TICK) * target.stat_get(STAT_KNOCKBACK_TAKEN); target.body.velocity = target.body.velocity + toward * pull; } for (int i = 0; i < (int)sim.world.dummies.size(); i++) { Dummy& dummy = sim.world.dummies[i]; Vec2 center = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h / 2}; if (dummy.tile >= 0 || !is_target(sim, MAX_FIGHTERS + i, attack.owner) || !is_in_cone(attack, center)) continue; Vec2 toward = vec2_normalize(attack.position - center); dummy.wobbleSpeed += toward.x * VACUUM_PULL_RATE * fighter.stat_get(STAT_KNOCKBACK) * WOBBLE_PER_PULL * TICK; } // Each interval (the hit cooldown counting it down), everyone in the cone can be nibbled again. float interval = fighter.level_get(attack.key).cooldown / fighter.stat_get(STAT_ATTACK_RATE); bool isPulse = attack.hitCooldown <= 0; if (isPulse) { attack.struck = 0; attack.hitCooldown = interval; } int targets = std::min(MAX_FIGHTERS + (int)sim.world.dummies.size(), STRUCK_MAX); for (int target = 0; target < targets; target++) { Rect box = target_box(sim, target); Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; if ((attack.struck >> target & 1) || !is_target(sim, target, attack.owner) || !is_in_cone(attack, center)) continue; attack.struck |= 1u << target; attack_hit(sim, attack, target, center); } // Only the first of a fan of copies (or repeats) lengthens lives, so they don't add up. bool isLengthening = isPulse && !attack.isCopy; shots_drift(sim, attack, 0, VACUUM_PULL_RATE * TICK, isLengthening ? VACUUM_RESTORE : 0); } // The Vacuum's airblast: once, as it goes off, its cone hits everyone in it for a little damage and knocks every // fighter in it away (launching them), both harder the closer they are, and turns every shot in it to fly out along // the aim, claiming other fighters' (all as good as fresh, see shots_drift). (The air it blows out is drawn from the // cone, see Scene::gust_draw.) void Weapons::gust_move(Sim& sim, Attack& attack) { attack.isDead = attack.life_get() >= attack.lifetime; if (attack.time > 0) return; int targets = std::min(MAX_FIGHTERS + (int)sim.world.dummies.size(), STRUCK_MAX); for (int target = 0; target < targets; target++) { Rect box = target_box(sim, target); Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; if (!is_target(sim, target, attack.owner) || !is_in_cone(attack, center)) continue; attack_hit(sim, attack, target, center); // Boxing Glove adds to it as to any knock. Vec2 push = vec2_normalize(center - attack.position) * attack.stats.speed * cone_strength_get(attack, center) * sim.fighters[attack.owner].stat_get(STAT_KNOCKBACK); if (target >= MAX_FIGHTERS) { dummy_push(sim.world.dummies[target - MAX_FIGHTERS], push); continue; } Fighter& fighter = sim.fighters[target]; fighter.knock_take(push * fighter.stat_get(STAT_KNOCKBACK_TAKEN), attack.owner); } shots_drift(sim, attack, attack.stats.speed * GUST_SHOT_SHARE, 0, attack.isCopy ? 0 : GUST_RESTORE); } // A weapon (by its key, see Fighter::weapon_key) is busy once as many of it are out of the hand (wielded attacks) as // its level allows. Splitter and Doubler copies don't count. bool Weapons::is_busy(Fighter& fighter, int key) { int out = 0; for (Attack& attack : attacks) { out += attack.owner == fighter.index && attack.key == key && !attack.isDead && !attack.isCopy && attack.def()->isWielded; } return out >= fighter.level_get(key).count; }