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

2379 lines
107 KiB
C++

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