Files
smack-a-brother/src/weapon.cpp
T
shweetandClaude Opus 5.5 56af1c357c Remove the input delay setting and players being away
Neither does anything with the server running the games. Counting down to play
again moves into the match, so the server and every player agree on it and the
results show it starting again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 02:21:57 -05:00

1945 lines
85 KiB
C++

#include "weapon.h"
#include "sim.h"
#include <algorithm>
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 = 4;
constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220};
constexpr float DEGREE = PI / 180;
constexpr float ARROW_LINGER = 2.5f;
constexpr float ARROW_SPAN = 18;
constexpr float ARROW_THICKNESS = 2;
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 FLAME_BRAKE = 0.9f;
constexpr float FLAME_BRAKE_REACH = 16;
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.006f;
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<Vec2> 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<Vec2> 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, the off-hand weapon fires too, for a fraction of
// its damage. Every weapon (see Fighter::weapon_key) keeps its own cooldown, in either 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});
}
}
int off = fighter.off_slot_get();
off = off < 0 ? -1 : fighter.weapon_key(off);
float dual = fighter.trinket_get(TRINKET_DUAL, 0);
if (dual > 0 && off >= 0 && off != held && fighter.cooldowns[off] <= 0 && !is_busy(fighter, off))
{
is_spawned(sim, fighter,
{.charge = charge, .power = dual, .weaponSlot = off, .isOffhand = true, .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(),
.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,
.isCharged = isCharged,
.isLeft = fighter.is_left(),
.isOffhand = shot.isOffhand,
.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);
// 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);
// Standing in your own lingering flames brakes you, launches included.
for (int i = 0; i < sim.fighterCount; i++)
{
Fighter& fighter = sim.fighters[i];
for (Attack& attack : attacks)
{
bool isNear = vec2_length(attack.position - fighter.center_get()) < FLAME_BRAKE_REACH;
if (attack.owner != i || attack.def()->kind != ATTACK_FLAME || attack.isDead || !isNear)
continue;
fighter.body.velocity = fighter.body.velocity * FLAME_BRAKE;
break;
}
}
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 : 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 = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get();
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 = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get();
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 = attack.isOffhand ? fighter.off_slot_get() : fighter.slot;
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<Attack*> 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.isOffhand && 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<Capsule> Weapons::chain_get(Sim& sim, Fighter& fighter)
{
std::vector<Capsule> links;
if (!fighter.is_trinket_equipped(TRINKET_TESLA))
return links;
float range = TESLA_RANGES[fighter.trinket_level_get(TRINKET_TESLA)];
std::vector<Vec2> 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<bool> isLinked(count * count, false);
std::vector<std::pair<float, int>> 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<Path> Weapons::paths_get(Sim& sim, Fighter& fighter)
{
std::vector<Path> paths;
int key = fighter.weapon_key(fighter.slot);
if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, false)))
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.
void Weapons::blast_spawn(Sim& sim, Blast blast)
{
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);
float reach = blast.area * 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();
if (blast.isPushing && fighter.isAlive && distance < reach)
fighter.impulse_add(vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED *
(1 - distance / reach) * absorb);
}
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, bool isOffhand)
{
for (Attack& attack : attacks)
{
AttackKind kind = attack.def()->kind;
if ((kind == ATTACK_WHIP || kind == ATTACK_HOOK) && attack.owner == fighter.index && attack.key == key &&
attack.isOffhand == isOffhand && !attack.isCopy && !attack.isDead)
return &attack;
}
return nullptr;
}
// The fighter's own attacks it can stand on or bump into: stuck arrows and charged boomerangs in flight.
std::vector<Rect> Weapons::solids_get(Fighter& fighter)
{
std::vector<Rect> solids;
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;
solids.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2});
}
if (kind == ATTACK_ARROW && attack.isAttached)
{
Vec2 middle = attack.position - vec2_polar(attack.angle, ARROW_SPAN / 2);
Vec2 half = {std::abs(math_cos(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS,
std::abs(math_sin(attack.angle)) * ARROW_SPAN / 2 + ARROW_THICKNESS};
solids.push_back({middle.x - half.x, middle.y - half.y, half.x * 2, half.y * 2});
}
}
return solids;
}
// 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;
}