#include "weapon.h" #include "sim.h" #include #include constexpr int ATTACKS_MAX = 255; constexpr int DAMAGE_STEP = 5; constexpr int HEAVY_DAMAGE = 40; constexpr int HOOK_SUBSTEPS = 4; constexpr int TESLA_LINKS = 5; constexpr float TESLA_INTERVAL = 0.2f; constexpr float TESLA_RADIUS = 2; constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220}; constexpr float DEGREE = PI / 180; constexpr float ARROW_LINGER = 2.5f; constexpr float ARROW_RADIUS = 6; constexpr float BEAM_RADIUS = 4; constexpr float BOOMERANG_LIFETIME = 6; constexpr float BOOMERANG_RADIUS = 9; constexpr float BOOMERANG_STEP = 2; constexpr float BULLET_RADIUS = 4; 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; constexpr float FALLOFF_RANGE = 600; constexpr float FILL_STRENGTH = 2; constexpr float TRAJECTORY_REACH = 400; constexpr float TRAJECTORY_STEP = 4; constexpr float FIRE_SHAKE = 0.5f; constexpr float FIREBALL_RADIUS = 5; constexpr float FLAIL_BOUNCE = 0.3f; constexpr float FLAIL_BOUNCE_MAX = 0.9f; constexpr float FLAIL_BOUNCE_PER_RICOCHET = 0.2f; constexpr float FLAIL_BOUNCE_MIN = 120; constexpr float FLAIL_FRICTION = 600; constexpr float FLAIL_RADIUS = 9; constexpr float FLAIL_SIZE = 12; constexpr float FLAIL_THUD_SPEED = 250; 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 FLAME_DRAG = 0.9f; constexpr float FLAME_RADIUS = 5; constexpr float FLAME_RISE = 100; constexpr float HAMMER_HEAD_RADIUS = 8; constexpr float HAMMER_REACH = 28; constexpr float HAMMER_REBOUND = 380; constexpr float HAMMER_REBOUND_CHARGED = 1.5f; constexpr float HOOK_RADIUS = 6; constexpr float HOOK_WHIFF_TIME = 0.1f; constexpr float KNOCK_LIGHT = 0.3f; constexpr float KNOCK_SLIGHT = 0.1f; constexpr float KNOCKBACK_REACH = 1.5f; constexpr float KNOCKBACK_SPEED = 550; constexpr float LANCE_LENGTH = 40; constexpr float LANCE_RADIUS = 3; constexpr float MUZZLE_DISTANCE = 18; constexpr float POKE_THRUST = 0.35f; constexpr float PROJECTILE_STEP = 8; constexpr float RAY_STEP = 2; constexpr float RECOIL_CURVE = 0.75f; constexpr float ROCKET_LIFETIME = 3; constexpr float ROCKET_RADIUS = 4; constexpr float ROCKET_TAIL = 7; constexpr float PUNCH_RADIUS = 6; constexpr float PUNCH_THRUST = 0.4f; constexpr float SLASH_DISTANCE = 12; constexpr float SLASH_GAP = 14; constexpr float SHAKE_PER_DAMAGE = 0.003f; constexpr float SLAM_RATIO = 0.75f; 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; constexpr float WISP_HOME_MIN = 10; constexpr float WISP_LAUNCH_STAGGER = 0.06f; constexpr float WISP_LIFETIME = 3; constexpr float WISP_RADIUS = 4; 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 WISP_TURN = 1.75f; constexpr float TELEPORT_PROBE = 4; constexpr float TELEPORT_STEP = 2; constexpr float CONTACT_KNOCK = 1; constexpr float EMBIGGEN_DAMAGE = 1 / 3.0f; constexpr float SPLASH_EDGE = 0.25f; constexpr float SPLIT_DAMAGE = 0.6f; 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 SLASH_RADIUS = 14; constexpr float SLASH_REACH = 26; constexpr float UPPERCUT_RADIUS = 14; constexpr float UPPERCUT_REACH = 16; constexpr float SWING_END = 0.3f * PI; constexpr float SWING_START = -0.85f * PI; constexpr float VOLLEY_GAP = 6; constexpr float WHIP_CONE = 0.25f; constexpr float WHIP_HANDLE = 4; constexpr float WHIP_LOOP = 9; constexpr int WHIP_LOOP_STEPS = 6; constexpr float WHIP_PULL = 220; constexpr float WOBBLE_IMPULSE = 0.15f; constexpr Vec2 HAMMER_HEAD = {20, 0}; constexpr float SLUG_RADIUS = 2; constexpr Burst EMBER_BURST = {.gravity = -150, .life = 0.35f, .size = 2, .speed = 60, .spread = 0.6f, .count = 1}; 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 towards 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 boomerang one tick toward its thrower's hand, 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 boomerang_return(Sim& sim, Fighter& fighter, Attack& attack) { Vec2 toHand = fighter.hand_get() - 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 * 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_MINE: sim.cues->sound_play(SOUND_IMPACT_MINE); break; case ATTACK_PROJECTILE: sim.cues->sound_play(SOUND_IMPACT_BLASTER); break; default: break; } } // Turns a projectile off the wall it's about to enter. Each ricochet doubles its later damage. 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.bounces--; attack.ricochets++; sim.cues->sound_play(SOUND_RICOCHET); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } // 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; } static bool is_falloff(AttackKind kind) { return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_BEAM || kind == ATTACK_HOOK || kind == ATTACK_WISP; } // Ranged shots lose damage linearly with distance flown: full at the muzzle, down to a floor far out. A beam's // distance runs along it to the point, a hook's is how far it reached from where it was thrown, and a wisp's counts // only from when it set off (not its orbiting). float falloff_at(float distance) { return std::max(FALLOFF_FLOOR, 1 - distance / FALLOFF_RANGE); } static float falloff_get(Attack& attack, Vec2 point) { AttackKind kind = attack.def()->kind; if (!is_falloff(kind)) return 1; float distance = attack.travelled + (kind == ATTACK_BEAM ? vec2_length(point - attack.position) : 0); if (kind == ATTACK_HOOK) distance = vec2_length(attack.position - attack.origin); if (kind == ATTACK_WISP) distance = attack.length; return falloff_at(distance); } // 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); } // How much of an attack's black fill its colour 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); } // How much of a hit's knockback an attack delivers: rapid shots and beams barely push, arrows and hooks a little, // and melee and explosions in full. static float knock_get(AttackKind kind) { if (kind == ATTACK_BULLET || kind == ATTACK_PROJECTILE || kind == ATTACK_BEAM) return KNOCK_SLIGHT; if (kind == ATTACK_ARROW || kind == ATTACK_HOOK) return KNOCK_LIGHT; return 1; } // A plain lash rolling out: straight from the handle to its front, round a loop that shrinks as it goes, then the rest // of the whip doubled back above, which starts out behind the fighter. It lies straight once it has cracked. std::vector lash_get(Attack& attack) { Vec2 direction = vec2_polar(attack.angle, 1); Vec2 up = attack.isLeft ? Vec2{-direction.y, direction.x} : Vec2{direction.y, -direction.x}; float front = vec2_length(attack.tip - attack.position); float unfurl = attack.length > 0 ? std::min(1.0f, front / attack.length) : 1; float radius = WHIP_LOOP * (1 - unfurl) * attack.scale; std::vector points = {attack.position, attack.tip}; if (radius < 1) return points; Vec2 center = attack.tip + up * radius; float start = vec2_angle(up * -1); float turn = vec2_dot(Vec2{up.y, -up.x}, direction) > 0 ? 1 : -1; for (int i = 1; i <= WHIP_LOOP_STEPS; i++) points.push_back(center + vec2_polar(start + turn * PI * i / WHIP_LOOP_STEPS, radius)); points.push_back(attack.tip + up * (radius * 2) - direction * (attack.length - front)); 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 to its unfurled front 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.isCharged) { shapes[0] = {attack.position, attack.tip, attack.radius}; return 1; } auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; float front = vec2_length(attack.tip - attack.origin); 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.trinket_get(TRINKET_GLOVES, 1); if (held >= 0 && fighter.cooldowns[held] <= 0 && !is_busy(fighter, held)) { bool isSpawned = is_spawned(sim, fighter, {.charge = charge, .weaponSlot = held}); fighter.cooldowns[held] = fighter.level_get(held).cooldown / gloves; // 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].values[fighter.trinketSlotLevels[i]]; 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].values[fighter.trinketSlotLevels[i]]; 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; is_spawned(sim, fighter, {.charge = charge, .power = powers[hand - 1], .weaponSlot = off, .hand = hand, .isSecondary = true}); fighter.cooldowns[off] = fighter.level_get(off).cooldown / gloves; } } // Whether an attack flies on its own, which the Accelerator speeds up. static bool is_projectile(AttackKind kind, bool isCharged) { return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET || kind == ATTACK_BOOMERANG || kind == ATTACK_FLAIL || kind == ATTACK_FLAME || kind == ATTACK_FIREBALL || kind == ATTACK_WISP || (kind == ATTACK_HOOK && !isCharged); } // Whether Ricochet lets an attack ricochet off walls. static bool is_bouncy(AttackKind kind) { return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET || kind == ATTACK_BEAM || kind == ATTACK_WISP; } // Shots flying on their own velocity gain the part of the fighter's velocity heading along the aim, so they still fly // where aimed and running away never slows them. Boomerangs fly their own path, and wisps set off from a standstill. static bool is_inheriting(AttackKind kind, bool isCharged) { return is_projectile(kind, isCharged) && kind != ATTACK_BOOMERANG && kind != ATTACK_WISP; } // Whether the fighter's box would fit with its middle at centre, clear of walls (and inside the map). static bool is_box_clear(Sim& sim, Fighter& fighter, Vec2 centre) { 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 = {centre.x - box.w / 2 + box.w * column / columns, centre.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 centre = fighter.center_get(); Vec2 offset = target - centre; float distance = std::min(range, vec2_length(offset)); Vec2 direction = vec2_normalize(offset); for (float along = distance; along > 0; along -= TELEPORT_STEP) { Vec2 spot = centre + direction * along; if (is_box_clear(sim, fighter, spot)) return spot; } return centre; } // 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 count only their push, everything else the fighter's whole speed. static float bonus_get(Fighter& fighter, AttackKind kind, bool isCharged, Vec2 aim) { float speed = is_inheriting(kind, isCharged) ? push_get(fighter, aim) : vec2_length(fighter.body.velocity); return 1 + (SPEED_DAMAGE + fighter.trinket_get(TRINKET_SPEEDOMETER, 0)) * speed / SPEED_UNIT; } // How much a fighter's colour 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; bool isCharged = fighter.charge >= 1; AttackKind kind = isCharged ? WEAPON_DEFS[held].charged.kind : WEAPON_DEFS[held].normal.kind; return fill_get(bonus_get(fighter, kind, isCharged, 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, 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, bool isCharged) { const WeaponDef& def = WEAPON_DEFS[weapon]; const AttackDef& attack = isCharged ? def.charged : def.normal; const LevelDef& levelDef = def.levels[level]; AttackStats stats = isCharged ? levelDef.charged : levelDef.normal; AttackKind kind = attack.kind; bool isTeleport = kind == ATTACK_TELEPORT; int splits = isTeleport ? 1 : (int)fighter.trinket_get(TRINKET_SPLITTER, 1); float splitScale = splits > 1 ? SPLIT_SCALE : 1; float embiggen = 1 + (fighter.trinket_get(TRINKET_EMBIGGENER, 1) - 1) * EMBIGGEN_DAMAGE; float enlarger = fighter.trinket_get(TRINKET_ENLARGER, 1); float gloves = fighter.trinket_get(TRINKET_GLOVES, 1); float drink = fighter.trinket_get(TRINKET_DRINK, 1); float accelerator = is_projectile(kind, isCharged) ? fighter.trinket_get(TRINKET_ACCELERATOR, 1) : 1; float steadiness = 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0); float absorb = fighter.trinket_get(TRINKET_ABSORBER, 1); bool isMelee = kind == ATTACK_SWING || kind == ATTACK_SLASH; AttackSummary summary; summary.isDamaging = kind != ATTACK_DETONATE && stats.damage > 0; float damage = splits > 1 ? std::max(1.0f, std::round(stats.damage * SPLIT_DAMAGE)) : stats.damage; summary.damage = std::round(damage * embiggen); summary.isDamageChanged = summary.damage != stats.damage; summary.every = isCharged ? CHARGE_TIME / drink : levelDef.cooldown / gloves; summary.isEveryChanged = isCharged ? drink != 1 : gloves != 1; summary.range = stats.range * (isMelee ? enlarger * splitScale : 1); summary.isRangeChanged = summary.range != stats.range; summary.area = stats.area * attack.scale * enlarger * splitScale; summary.isAreaChanged = enlarger != 1 || splits > 1; summary.speed = is_projectile(kind, isCharged) ? 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 && !isCharged) 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); bool isCharged = charge >= 1; const AttackDef& def = isCharged ? weapon.charged : weapon.normal; AttackStats stats = isCharged ? level.charged : level.normal; if (weapon.isDrawn) 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); } 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_mine_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; } if (is_projectile(kind, isCharged)) stats.speed *= fighter.trinket_get(TRINKET_ACCELERATOR, 1); float spread = 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0); float jitter = def.spread * DEGREE * spread; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.rng.random_get(-jitter, jitter), 1); float push = is_inheriting(kind, isCharged) ? push_get(fighter, aim) : 0; // A part-charged normal attack (not a drawn one, whose draw already strengthens it) adds to the speed bonus, so it // hits harder and fills in, glows and shakes the same way. float bonus = bonus_get(fighter, kind, isCharged, aim); if (!isCharged && !weapon.isDrawn) 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 = stats.duration > 0 ? stats.duration : def.duration, .scale = def.scale * stats.scale, .bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0, .hitsLeft = def.hits, .owner = fighter.index, .weapon = held, .key = key, .hand = shot.hand, .isCharged = isCharged, .isLeft = fighter.is_left(), .isSecondary = shot.isSecondary, }; attack.position = attack.origin + aim * MUZZLE_DISTANCE; // 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.trinket_get(TRINKET_ENLARGER, 1); 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; if (kind == ATTACK_SWING) attack.scale = stats.range / HAMMER_REACH; if (kind == ATTACK_SLASH) attack.scale = stats.range / SLASH_REACH; attack.scale *= fighter.trinket_get(TRINKET_ENLARGER, 1); if (kind == ATTACK_POKE) attack.duration = std::max(TICK, stats.range - LANCE_LENGTH * attack.scale) / stats.speed / POKE_THRUST; int splits = fighter.trinket_get(TRINKET_SPLITTER, 1); if (splits > 1) { attack.scale *= SPLIT_SCALE; attack.stats.damage = std::max(1.0f, std::round(attack.stats.damage * SPLIT_DAMAGE)); } // One piece per volley shot per split, each fanned around the aim (or set side by side when the volley has no // fan). The centre 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); piece.position = piece.origin + vec2_polar(piece.angle, MUZZLE_DISTANCE); piece.isSecondary |= split != splits / 2; piece.isCopy = shot.isEcho || split != splits / 2; if (kind == ATTACK_WISP) { // 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 wisps_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); } piece_launch(sim, piece); attacks.push_back(piece); } } sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound); Vec2 muzzle = attack.origin + aim * MUZZLE_DISTANCE; float side = fighter.is_left() ? -1 : 1; if (weapon.flash > 0) sim.cues->flash_spawn(muzzle, vec2_angle(aim), weapon.flash, fighter.index); if (weapon.isEjecting) sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), fighter.index); if (isCharged) sim.cues->shake_add(def.shake * FIRE_SHAKE); // A fireball goes off with an airblast at the muzzle, as wide as the fireball's own blast. if (kind == ATTACK_FIREBALL) blast_spawn(sim, {.position = muzzle, .area = stats.area * fighter.trinket_get(TRINKET_ENLARGER, 1), .owner = fighter.index, .isHarmless = true}); // An uppercut launches its fighter the way it rises, cancelling any speed against that first. Shock Absorber // softens every push the shot gives its own fighter: lunges, kickback and Recoil Coil. float absorb = fighter.trinket_get(TRINKET_ABSORBER, 1); 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.trinket_get(TRINKET_RECOIL, 0) * 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 (is_falloff(attack.def()->kind)) attack.travelled += vec2_length(attack.position - attack.positionPrevious); if (isNew) attack_remember(attack); attack.hitCooldown -= TICK; AttackKind kind = attack.def()->kind; bool isResting = (kind == ATTACK_MINE && attack.time >= attack.duration) || kind == ATTACK_FLAME; bool isLine = kind == ATTACK_BEAM || kind == ATTACK_WHIP; if (!isLine && kind != ATTACK_EXPLOSION && !attack.isAttached && !isResting) sim.cues->attack_afterimage_spawn(attack); if (!attack.isAttached) 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; if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || !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 in or tethered // to a 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; AttackKind kind = attack.def()->kind; bool isHeld = kind == ATTACK_SWING || kind == ATTACK_POKE || kind == ATTACK_SPIN || kind == ATTACK_FLAIL || kind == ATTACK_WHIP || kind == ATTACK_BEAM || kind == ATTACK_HOOK || 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.time / attack.duration; float range = attack.stats.range; switch (def.kind) { case ATTACK_SWING: { // The overhead arc is laid out around the aim, mirrored when facing left. float swing = SWING_START + (SWING_END - SWING_START) * std::min(1.0f, progress * progress); float side = attack.isLeft ? -1 : 1; attack.angle = attack.aimAngle + swing * side; attack.origin = fighter.hand_get(); attack.position = attack.origin + vec2_polar(attack.angle, HAMMER_HEAD.x * attack.scale) + vec2_polar(attack.angle + PI / 2, HAMMER_HEAD.y * attack.scale * side); attack.radius = HAMMER_HEAD_RADIUS * attack.scale; // 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.isCharged ? HAMMER_REBOUND_CHARGED : 1) * fighter.trinket_get(TRINKET_ABSORBER, 1)); attack.isRebounded = true; sim.cues->sound_play(SOUND_BOUNCE); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } if (progress < 1) break; attack.isDead = true; if (!attack.isCharged) 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_SLASH: attack.position = attack.origin + vec2_polar(attack.angle, SLASH_DISTANCE * attack.scale + SLASH_GAP) + attack.velocity * attack.time; attack.radius = SLASH_RADIUS * attack.scale; attack.isDead = progress >= 1; break; case ATTACK_PUNCH: attack.origin = fighter.hand_get(); attack.position = attack.origin + vec2_polar(attack.angle, range * std::min(1.0f, progress / PUNCH_THRUST)); attack.radius = PUNCH_RADIUS * attack.scale; attack.isDead = progress >= 1; 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.trinket_get(TRINKET_SPLITTER, 1) > 1) reach /= SPLIT_SCALE; attack.origin = fighter.center_get(); attack.position = attack.origin + vec2_polar(attack.aimAngle, reach); attack.radius = UPPERCUT_RADIUS * attack.scale; attack.isDead = progress >= 1; break; } case ATTACK_POKE: { float extend = progress < POKE_THRUST ? progress / POKE_THRUST : (1 - progress) / (1 - POKE_THRUST); attack.origin = fighter.hand_get(); attack.position = attack.origin + vec2_polar(attack.angle, std::max(0.0f, extend) * (range - LANCE_LENGTH * attack.scale)); attack.tip = attack.position + vec2_polar(attack.angle, LANCE_LENGTH * attack.scale); attack.radius = LANCE_RADIUS * attack.scale; attack.isDead = progress >= 1; if (attack.isCharged && progress < POKE_THRUST) sim.cues->burst_spawn(TRAIL_BURST, attack.tip, attack.angle + PI, attack.owner); break; } case ATTACK_ARROW: if (attack.isAttached) { attack.isDead = attack.time >= attack.duration; break; } attack.velocity.y += ARROW_GRAVITY * TICK; [[fallthrough]]; case ATTACK_BULLET: attack.angle = vec2_angle(attack.velocity); [[fallthrough]]; case ATTACK_PROJECTILE: { // Small steps so fast shots can't skip through thin walls. The hit capsule spans the whole tick's // path so they can't skip past targets either. Vec2 start = attack.position; int steps = vec2_length(attack.velocity) * TICK / PROJECTILE_STEP + 1; attack.radius = (def.kind == ATTACK_BULLET ? SLUG_RADIUS : def.kind == ATTACK_ARROW ? ARROW_RADIUS : BULLET_RADIUS) * attack.scale; for (int step = 0; step < steps && !attack.isDead; step++) { Vec2 next = attack.position + attack.velocity * (TICK / steps); bool isWall = sim.world.is_blocking_at(next); if (isWall && attack.bounces > 0) { ricochet(sim, attack, next); continue; } // Arrows stick where they land and linger, harmless, as footholds for their archer. Blocks take the hit // instead. if (isWall && def.kind == ATTACK_ARROW && sim.world.tile_at(next) != TILE_BLOCK) { attack.isAttached = true; attack.hitsLeft = 0; attack.velocity = {}; attack.duration = attack.time + ARROW_LINGER; impact_play(sim, def.kind); sim.cues->burst_spawn(IMPACT_BURST, next, 0, attack.owner); break; } attack.position = next; bool isBlocked = sim.world.is_blocking_at(attack.position); if (!isBlocked && vec2_length(attack.position - attack.origin) < range) continue; attack.isDead = true; if (isBlocked) impact_play(sim, def.kind); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } attack.tip = start; break; } case ATTACK_ROCKET: { float speed = std::min(attack.stats.speed * ROCKET_SPEEDUP, vec2_length(attack.velocity) + ROCKET_ACCELERATION * TICK); attack.velocity = vec2_polar(attack.angle, speed); attack.radius = ROCKET_RADIUS * attack.scale; Vec2 next = attack.position + attack.velocity * TICK; if (sim.world.is_blocking_at(next) && attack.bounces > 0) { ricochet(sim, attack, next); break; } attack.position = next; sim.cues->burst_spawn(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL), attack.angle + PI, attack.owner); bool isSpent = attack.time > ROCKET_LIFETIME || vec2_length(attack.position - attack.origin) > range; if (sim.world.is_blocking_at(attack.position) || isSpent) attack_explode(sim, attack); break; } case ATTACK_WISP: wisp_move(sim, attack); break; case ATTACK_TELEPORT: teleport_move(sim, attack); break; case ATTACK_DETONATE: break; case ATTACK_EXPLOSION: attack.radius = range; attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft; 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.radius = BEAM_RADIUS * attack.scale; attack.isDead = progress >= 1; attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft; 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 rolls out from the handle, going with the hand, sweeping down across its cone from // the upper edge, and hits anywhere in the cone out to its front until it cracks at full reach (short of // any wall). attack.origin = fighter.hand_get(attack.hand); attack.radius = BEAM_RADIUS * attack.scale; attack.isDead = progress >= 1; if (attack.isCharged) { attack.angle = vec2_angle(attack.tip - attack.origin); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft; break; } auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; float unfurl = std::min(1.0f, progress / WHIP_UNFURL); attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * WHIP_CONE * (2 * unfurl - 1); Vec2 reach = ray_march(attack.origin, attack.angle, range, isBlocked); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); attack.length = std::max(0.0f, vec2_length(reach - attack.position)); attack.tip = attack.position + vec2_polar(attack.angle, attack.length * unfurl * (2 - unfurl)); attack.hitsLeft = unfurl < 1 ? attack.hitsLeft : 0; break; } case ATTACK_BOOMERANG: { // Flies out easing to its peak, hovers there for its hold, then comes back to the hand. Touching a surface // on the way out ends the throw there: a plain one turns straight back, a charged one hovers where it hit. // Coming back, it slides along surfaces, and breaks off if it's wedged with nowhere to go. attack.isReturning |= attack.time >= attack.duration + def.hold; attack.radius = BOOMERANG_RADIUS * attack.scale; if (attack.isReturning) { attack.isDead = boomerang_return(sim, fighter, attack) || attack.time > BOOMERANG_LIFETIME; break; } if (attack.time >= attack.duration) break; // Walked in small steps so it can't skip through a thin platform. float eased = 1 - (1 - progress) * (1 - progress); Vec2 next = attack.origin + vec2_polar(attack.angle, range * eased); Vec2 start = attack.position; int steps = vec2_length(next - start) / BOOMERANG_STEP + 1; bool isBlocked = false; for (int step = 1; step <= steps && !isBlocked; step++) { Vec2 point = vec2_lerp(start, next, (float)step / steps); isBlocked = sim.world.is_blocking_at(point); if (!isBlocked) attack.position = point; } if (!isBlocked) break; impact_play(sim, def.kind); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); attack.duration = attack.time; attack.isReturning = !attack.isCharged; break; } case ATTACK_SPIN: { // Keeps circling while left is held, finishing the current lap once let go. int hand = fighter.hand_slot_get(attack.hand); bool isHeld = fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key; if (isHeld && attack.time >= attack.duration) { attack.time -= attack.duration; sim.cues->sound_play(def.sound); } attack.angle += (attack.isLeft ? -2 : 2) * PI * TICK / attack.duration; attack.position = fighter.center_get() + vec2_polar(attack.angle, range); attack.radius = FLAIL_RADIUS * attack.scale; attack.isDead = !isHeld && progress >= 1; break; } case ATTACK_FLAME: { // Slows quickly out of the nozzle and drifts upward, stopping against walls. attack.velocity = attack.velocity * FLAME_DRAG + Vec2{0, -FLAME_RISE * TICK}; Vec2 next = attack.position + attack.velocity * TICK; if (sim.world.is_blocking_at(next)) attack.velocity = {}; else attack.position = next; attack.radius = FLAME_RADIUS * attack.scale; attack.isDead = progress >= 1; break; } case ATTACK_FIREBALL: attack.radius = FIREBALL_RADIUS * attack.scale; attack.position = attack.position + attack.velocity * TICK; sim.cues->burst_spawn(EMBER_BURST, attack.position, attack.angle + PI, attack.owner); if (sim.world.is_blocking_at(attack.position) || vec2_length(attack.position - attack.origin) > range) attack_explode(sim, attack); break; case ATTACK_MINE: { // Glides out along the aim, easing to a stop, and floats there, arming (and blinking) a moment later. Walls // stop it early. if (progress >= 1) break; float eased = 1 - (1 - progress) * (1 - progress); Vec2 next = attack.origin + vec2_polar(attack.angle, range * eased); if (sim.world.is_blocking_at(next)) { attack.duration = attack.time; impact_play(sim, def.kind); } else attack.position = next; break; } case ATTACK_FLAIL: attack.radius = FLAIL_RADIUS * attack.scale; if (attack.isReturning) attack.isDead = is_caught(fighter, attack); else flail_move(sim, attack); break; case ATTACK_HOOK: { attack.radius = HOOK_RADIUS * attack.scale; if (attack.isAttached) break; if (attack.isCharged) { attack.isDead = attack.time >= HOOK_WHIFF_TIME; break; } for (int step = 0; step < HOOK_SUBSTEPS && !attack.isAttached; step++) { attack.position = attack.position + attack.velocity * (TICK / HOOK_SUBSTEPS); if (sim.world.is_blocking_at(attack.position)) hook_attach(sim, attack); } attack.isDead = !attack.isAttached && vec2_length(attack.position - fighter.hand_get()) > range; break; } } bool isShot = def.kind == ATTACK_PROJECTILE || def.kind == ATTACK_BULLET || def.kind == ATTACK_ARROW; if (def.kind != ATTACK_POKE && def.kind != ATTACK_BEAM && def.kind != ATTACK_WHIP && !isShot) attack.tip = attack.position; } // 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 centre, down to a fraction at the edge, by the target's nearest point. float taper = 1; 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)); bool isHeavy = damage >= HEAVY_DAMAGE; SoundId sound = hit_sound_get(attack.weapon >= 0 ? WEAPON_DEFS[attack.weapon].hit : HIT_BLUNT, isHeavy); attack.hitsLeft--; attack.hitCooldown = HIT_INTERVAL; bool isShot = def.kind == ATTACK_PROJECTILE || def.kind == ATTACK_BULLET || def.kind == ATTACK_ARROW || def.kind == ATTACK_WISP; attack.isDead |= isShot && attack.hitsLeft <= 0; if (def.kind == ATTACK_HOOK) 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 : knock_get(def.kind), .amount = damage, .owner = attack.owner, .target = target, .sound = sound, }); } // 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 (a third of its growth). // 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); float embiggen = attacker.trinket_get(TRINKET_EMBIGGENER, 1); damage.amount = std::max(1, (int)std::round(damage.amount * (1 + (embiggen - 1) * EMBIGGEN_DAMAGE))); float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1; attacker.hitstop = std::max(attacker.hitstop, damage.hitstop); damage.knock *= attacker.trinket_get(TRINKET_BOXING, 1); 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.wobbleSpeed += side * WOBBLE_IMPULSE * damage.amount; 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.trinket_get(TRINKET_PEBBLE, 0); 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.trinket_get(TRINKET_SPEEDOMETER, 0)) * 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 >= HEAVY_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()->kind == ATTACK_WISP && !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::wisps_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 wisps: glide out to their place in the fighter's orbit (see orbits_update) and circle with it until their // moment comes, then set off toward wherever the fighter's cursor is right then (plus their spread) and drift only // very slightly after it (not once they're on top of it), fading out at the end of their range or against a wall (or // ricocheting off it). They hit anything they touch from the start, orbiting included, and drift through platforms. void Weapons::wisp_move(Sim& sim, Attack& attack) { Fighter& fighter = sim.fighters[attack.owner]; attack.radius = WISP_RADIUS * attack.scale; if (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; return; } Vec2 toward = fighter.controls.cursor - attack.position; if (attack.velocity.x == 0 && attack.velocity.y == 0) { attack.angle += vec2_angle(toward); attack.velocity = vec2_polar(attack.angle, attack.stats.speed); sim.cues->sound_play(SOUND_WISP); } else if (vec2_length(toward) > WISP_HOME_MIN) { float turn = std::remainder(vec2_angle(toward) - attack.angle, 2 * PI); attack.angle += std::clamp(turn, -WISP_TURN * TICK, WISP_TURN * TICK); attack.velocity = vec2_polar(attack.angle, vec2_length(attack.velocity)); } Vec2 next = attack.position + attack.velocity * TICK; if (sim.world.is_solid_at(next) && attack.bounces > 0) { ricochet(sim, attack, next); return; } attack.length += vec2_length(next - attack.position); attack.position = next; bool isBlocked = sim.world.is_solid_at(attack.position); if (!isBlocked && attack.length < attack.stats.range && attack.time < WISP_LIFETIME) return; attack.isDead = true; if (isBlocked) impact_play(sim, ATTACK_PROJECTILE); sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } // 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 neighbours within reach, so everything nearby is connected without the // links growing out of hand for rapid-fire weapons. std::vector Weapons::chain_get(Sim& sim, Fighter& fighter) { std::vector links; if (!fighter.is_trinket_equipped(TRINKET_TESLA)) return links; float range = TESLA_RANGES[fighter.trinket_level_get(TRINKET_TESLA)]; std::vector pieces; for (Attack& attack : attacks) if (attack.owner == fighter.index && !attack.isDead && attack.def()->kind != ATTACK_EXPLOSION) 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], TESLA_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.trinket_get(TRINKET_TESLA, 0); 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 = KNOCK_SLIGHT, .amount = (int)damage, .owner = i, .target = target, .sound = SOUND_ZAP}); } } } } // 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); bool isCharged = fighter.charge >= 1; const AttackDef& def = isCharged ? weapon.charged : weapon.normal; AttackKind kind = def.kind; AttackStats stats = isCharged ? level.charged : level.normal; if (weapon.isDrawn) 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.trinket_get(TRINKET_ENLARGER, 1)}; float length = vec2_length(spot - from); for (float travelled = 0; travelled < length; travelled += TRAJECTORY_STEP) path.steps.push_back(from + (spot - from) * (travelled / length)); path.steps.push_back(spot); paths.push_back(path); 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 && !isCharged); 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. int splits = fighter.trinket_get(TRINKET_SPLITTER, 1); float area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1) * (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.trinket_get(TRINKET_DIAZEPAM, 0)) : 0; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); float speed = stats.speed * fighter.trinket_get(TRINKET_ACCELERATOR, 1) + push_get(fighter, aim); Vec2 velocity = isArrow ? aim * speed : aim; Vec2 point = fighter.hand_get() + aim * MUZZLE_DISTANCE; int bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0; Path path; bool isEnded = stats.range <= TRAJECTORY_REACH; for (float travelled = area > 0 ? MUZZLE_DISTANCE : 0; travelled < reach; travelled += 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 += isArrow ? ARROW_GRAVITY * time : 0; 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 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.isPulling = attack.isCharged; 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); } // Traces instant attacks for one piece: beams march to the first wall and reflect off any Ricochet bounces, // whips lash from the hand to the first wall (pulling their wielder toward it, and charged ones blowing up at the // tip), and charged hooks plunge into // the first grabbable surface or target (dummy or enemy fighter). void Weapons::piece_launch(Sim& sim, Attack& piece) { if (piece.def()->kind == ATTACK_WHIP) { auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; piece.position = piece.origin; piece.tip = ray_march(piece.origin, piece.angle, piece.stats.range, isBlocked); Vec2 lash = piece.tip - piece.origin; if (!piece.isCopy && vec2_length(lash) < piece.stats.range - RAY_STEP) sim.fighters[piece.owner].impulse_add(vec2_normalize(lash) * WHIP_PULL * sim.fighters[piece.owner].trinket_get(TRINKET_ABSORBER, 1)); if (piece.isCharged) 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 charged crack already carries it .weapon = piece.weapon, .isSecondary = piece.isSecondary, }); } if (piece.def()->kind == ATTACK_BEAM) { auto isBlocked = [&](Vec2 point) { return sim.world.is_blocking_at(point); }; piece.tip = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); sim.cues->burst_spawn(SPARK_BURST, piece.tip, piece.angle + PI, piece.owner); Attack segment = piece; for (int i = 0; i < piece.bounces; i++) { segment.travelled += vec2_length(segment.tip - segment.position); 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 (piece.def()->kind == ATTACK_HOOK && piece.isCharged) { auto isBlocked = [&](Vec2 point) { for (int target = 0; 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); }; piece.position = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); if (sim.world.is_blocking_at(piece.position)) hook_attach(sim, piece); } } // 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 centre, harder the closer they are. A harmless blast (an airblast) only knocks everyone but its // owner within its area, launching them if close enough, shown as a ring. void Weapons::blast_spawn(Sim& sim, Blast blast) { float reach = blast.area * (blast.isHarmless ? 1 : KNOCKBACK_REACH); 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. float absorb = i == blast.owner ? fighter.trinket_get(TRINKET_ABSORBER, 1) : fighter.knock_absorb_get(); bool isPushed = blast.isHarmless ? i != blast.owner : blast.isPushing; if (!isPushed || !fighter.isAlive || distance >= reach) continue; Vec2 push = vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * (1 - distance / reach) * absorb; if (blast.isHarmless) fighter.knock_take(push, blast.owner); else fighter.impulse_add(push); } if (blast.isHarmless) { sim.cues->ring_spawn(blast.position, reach, blast.owner); return; } 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.isCharged) { float size = BOOMERANG_RADIUS * attack.scale; 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.time < attack.duration - 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. 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; } // Swings the thrown ball under gravity on its chain, then lets the world's tile collision move it, // adding a small bounce and ground friction. Hard landings thud. void Weapons::flail_move(Sim& sim, Attack& attack) { Fighter& fighter = sim.fighters[attack.owner]; attack.velocity.y += GRAVITY * TICK; Vec2 hand = fighter.hand_get(); Vec2 offset = attack.position + attack.velocity * TICK - hand; if (vec2_length(offset) > attack.stats.range) attack.velocity = (hand + vec2_normalize(offset) * attack.stats.range - attack.position) * (1 / TICK); float size = FLAIL_SIZE * attack.scale; Body body = { .box = {attack.position.x - size / 2, attack.position.y - size / 2, size, size}, .velocity = attack.velocity, }; Vec2 before = body.velocity; sim.world.body_move(body); bool isThudX = body.velocity.x == 0 && std::abs(before.x) > FLAIL_THUD_SPEED; bool isThudY = body.velocity.y == 0 && std::abs(before.y) > FLAIL_THUD_SPEED; if (isThudX || isThudY) impact_play(sim, ATTACK_FLAIL); float bounce = std::min(FLAIL_BOUNCE_MAX, FLAIL_BOUNCE + FLAIL_BOUNCE_PER_RICOCHET * fighter.trinket_get(TRINKET_RICOCHET, 0)); if (body.velocity.x == 0 && std::abs(before.x) > FLAIL_BOUNCE_MIN) body.velocity.x = -before.x * bounce; if (body.velocity.y == 0 && std::abs(before.y) > FLAIL_BOUNCE_MIN) body.velocity.y = -before.y * bounce; if (body.isGrounded) body.velocity.x = std::clamp(0.0f, body.velocity.x - FLAIL_FRICTION * TICK, body.velocity.x + FLAIL_FRICTION * TICK); attack.position = {body.box.x + size / 2, body.box.y + size / 2}; attack.velocity = body.velocity; } // Anything that leaves the hand (swings, pokes, throws, hooks) takes the weapon out of it. // A weapon (by its key, see Fighter::weapon_key) is busy once as many are out 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) { AttackKind kind = attack.def()->kind; bool isHeld = kind == ATTACK_SWING || kind == ATTACK_POKE || kind == ATTACK_BOOMERANG || kind == ATTACK_HOOK || kind == ATTACK_SPIN || kind == ATTACK_FLAIL; out += attack.owner == fighter.index && attack.key == key && !attack.isDead && !attack.isCopy && isHeld; } return out >= fighter.level_get(key).count; }