Files
smack-a-brother/src/weapon.cpp
T
shweetandClaude Opus 5.5 6f112ab231 HUD pass, weapon and trinket rework, and font arrows
HUD:
- Hotbars fit a quarter of the screen, dropping to small then tiny slots
  (thinner outlines when tiny), with cooldown bars matching their size.
- HUD elements are drawn on their own layer and composited at one
  opacity: hotbar slots are see-through until hovered, the rest fade
  while hovered.
- Rival windows fit the right column in at most two rows, shrinking
  icons then font, then dropping icons for name, health and ping bars.
- Tooltips number equipped items' slots and say Recoil for weapons that
  only push back. Previews use arrows, spread uses degrees.
- Crosshair fills in and shakes with charge, and weapons shake after
  firing until their cooldown ends. DPS and speed meters off by default.

Gameplay:
- Dual Wield stacks: each copy fires another recently held weapon from
  a fanned off-hand, at its own level's cut.
- Flamethrower: no slowing, smaller fireball with some recoil, and an
  airblast that knocks others away.
- Iron Weights slam on any landing faster than a plain fall (platforms
  too), stacking radius and damage, and speeding fast falls.
- Stuck arrows are one-way ledges until they fade, matching their
  (slightly larger) hit size. Attacks now collide with slopes.
- Halved Tesla Coil, Recoil Coil, Diazepam, Shock Absorber, Feather and
  screenshake values.

Art and tools:
- Weapon and trinket icons merged into one sheet each (normal, small,
  tiny), cells in catalog order. Grimoire wisps stand upright.
- Fonts gain the degree sign and four arrows, decoded from UTF-8.
- Menu fighter preview walks at quarter speed.
- Unequip from either catalog tab by right click or gamepad.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 16:43:45 -05:00

1961 lines
86 KiB
C++

#include "weapon.h"
#include "sim.h"
#include <algorithm>
#include <functional>
constexpr int ATTACKS_MAX = 255;
constexpr int DAMAGE_STEP = 5;
constexpr int HEAVY_DAMAGE = 40;
constexpr int HOOK_SUBSTEPS = 4;
constexpr int TESLA_LINKS = 5;
constexpr float TESLA_INTERVAL = 0.2f;
constexpr float TESLA_RADIUS = 2;
constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220};
constexpr float DEGREE = PI / 180;
constexpr float ARROW_LINGER = 2.5f;
constexpr float ARROW_RADIUS = 6;
constexpr float BEAM_RADIUS = 4;
constexpr float BOOMERANG_LIFETIME = 6;
constexpr float BOOMERANG_RADIUS = 9;
constexpr float BOOMERANG_STEP = 2;
constexpr float BULLET_RADIUS = 4;
constexpr float CASING_BACK = 0.4f;
constexpr float CAPSULE_STEP = 4;
constexpr float CATCH_DISTANCE = 10;
constexpr float DOUBLER_DELAY = 0.15f;
constexpr float FALLOFF_FLOOR = 0.1f;
constexpr float FALLOFF_RANGE = 600;
constexpr float FILL_STRENGTH = 2;
constexpr float TRAJECTORY_REACH = 400;
constexpr float TRAJECTORY_STEP = 4;
constexpr float FIRE_SHAKE = 0.5f;
constexpr float FIREBALL_RADIUS = 5;
constexpr float FLAIL_BOUNCE = 0.3f;
constexpr float FLAIL_BOUNCE_MAX = 0.9f;
constexpr float FLAIL_BOUNCE_PER_RICOCHET = 0.2f;
constexpr float FLAIL_BOUNCE_MIN = 120;
constexpr float FLAIL_FRICTION = 600;
constexpr float FLAIL_RADIUS = 9;
constexpr float FLAIL_SIZE = 12;
constexpr float FLAIL_THUD_SPEED = 250;
constexpr float HIT_INTERVAL = 0.25f;
constexpr float HITSTOP_MAX = 3;
constexpr float HITSTOP_PER_DAMAGE = 0.002f;
constexpr float HITSTOP_MIN_DAMAGE = 10;
constexpr float HIT_PARTICLES_PER_DAMAGE = 0.2f;
constexpr float FLAME_DRAG = 0.9f;
constexpr float FLAME_RADIUS = 5;
constexpr float FLAME_RISE = 100;
constexpr float HAMMER_HEAD_RADIUS = 8;
constexpr float HAMMER_REACH = 28;
constexpr float HAMMER_REBOUND = 380;
constexpr float HAMMER_REBOUND_CHARGED = 1.5f;
constexpr float HOOK_RADIUS = 6;
constexpr float HOOK_WHIFF_TIME = 0.1f;
constexpr float KNOCK_LIGHT = 0.3f;
constexpr float KNOCK_SLIGHT = 0.1f;
constexpr float KNOCKBACK_REACH = 1.5f;
constexpr float KNOCKBACK_SPEED = 550;
constexpr float LANCE_LENGTH = 40;
constexpr float LANCE_RADIUS = 3;
constexpr float MUZZLE_DISTANCE = 18;
constexpr float POKE_THRUST = 0.35f;
constexpr float PROJECTILE_STEP = 8;
constexpr float RAY_STEP = 2;
constexpr float RECOIL_CURVE = 0.75f;
constexpr float ROCKET_LIFETIME = 3;
constexpr float ROCKET_RADIUS = 4;
constexpr float ROCKET_TAIL = 7;
constexpr float PUNCH_RADIUS = 6;
constexpr float PUNCH_THRUST = 0.4f;
constexpr float SLASH_DISTANCE = 12;
constexpr float SLASH_GAP = 14;
constexpr float SHAKE_PER_DAMAGE = 0.003f;
constexpr float SLAM_RATIO = 0.75f;
constexpr float SPEED_DAMAGE = 0.05f;
// A normal attack let go partway through a charge hits harder, up to this much more just short of full.
constexpr float PARTIAL_CHARGE_BONUS = 0.5f;
constexpr float SPEED_UNIT = 100;
constexpr float CONTACT_INTERVAL = 0.4f;
constexpr float WISP_GLIDE = 14;
constexpr float WISP_HOME_MIN = 10;
constexpr float WISP_LAUNCH_STAGGER = 0.06f;
constexpr float WISP_LIFETIME = 3;
constexpr float WISP_RADIUS = 4;
constexpr float WISP_RELEASE_SPAN = 0.6f;
constexpr float WISP_RING = 18;
constexpr float WISP_RING_GAP = 10;
constexpr float WISP_SPACING = 12;
constexpr float WISP_SPIN = 2.5f;
constexpr float WISP_TURN = 1.75f;
constexpr float TELEPORT_PROBE = 4;
constexpr float TELEPORT_STEP = 2;
constexpr float CONTACT_KNOCK = 1;
constexpr float EMBIGGEN_DAMAGE = 1 / 3.0f;
constexpr float SPLASH_EDGE = 0.25f;
constexpr float SPLIT_DAMAGE = 0.6f;
constexpr float SPLIT_SCALE = 0.75f;
constexpr float SPLIT_GAP = 0.06f;
constexpr float SPLIT_GAP_MELEE = 0.5f;
constexpr float SPLIT_GAP_THROWN = 0.3f;
constexpr float SLASH_RADIUS = 14;
constexpr float SLASH_REACH = 26;
constexpr float UPPERCUT_RADIUS = 14;
constexpr float UPPERCUT_REACH = 16;
constexpr float SWING_END = 0.3f * PI;
constexpr float SWING_START = -0.85f * PI;
constexpr float VOLLEY_GAP = 6;
constexpr float WHIP_CONE = 0.25f;
constexpr float WHIP_HANDLE = 4;
constexpr float WHIP_LOOP = 9;
constexpr int WHIP_LOOP_STEPS = 6;
constexpr float WHIP_PULL = 220;
constexpr float WOBBLE_IMPULSE = 0.15f;
constexpr Vec2 HAMMER_HEAD = {20, 0};
constexpr float SLUG_RADIUS = 2;
constexpr Burst EMBER_BURST = {.gravity = -150, .life = 0.35f, .size = 2, .speed = 60, .spread = 0.6f, .count = 1};
constexpr Burst CASING_BURST = {.gravity = 700, .life = 0.6f, .size = 1, .speed = 110, .spread = 0.35f, .count = 1};
constexpr Burst BLAST_BURST = {.gravity = 200, .life = 0.5f, .size = 4, .speed = 220, .count = 20};
constexpr Burst HIT_BURST = {.gravity = 300, .speed = 180, .spread = PI / 2, .count = 6};
constexpr Burst IMPACT_BURST = {.speed = 80, .count = 5};
constexpr Burst SLAM_BURST = {
.angle = -PI / 2, .gravity = 600, .life = 0.6f, .size = 4, .speed = 260, .spread = PI / 2.5f, .count = 24};
constexpr Burst SMOKE_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.4f, .count = 1};
constexpr Burst SPARK_BURST = {.speed = 200, .spread = PI / 2, .count = 12};
constexpr Burst TRAIL_BURST = {.life = 0.25f, .size = 2, .speed = 120, .spread = 0.2f, .count = 2};
constexpr Burst WARP_BURST = {.gravity = -60, .life = 0.4f, .size = 2, .speed = 50, .spread = PI, .count = 1};
constexpr Burst PORTAL_BURST = {.life = 0.4f, .size = 3, .speed = 140, .spread = PI, .count = 14};
// Flies a thrown weapon back towards the hand, reporting whether it got there.
static bool is_caught(Fighter& fighter, Attack& attack)
{
Vec2 toHand = fighter.hand_get() - attack.position;
float distance = vec2_length(toHand);
attack.position = attack.position + vec2_normalize(toHand) * std::min(distance, attack.stats.speed * TICK);
return distance < CATCH_DISTANCE;
}
// Moves a returning boomerang one tick toward its thrower's hand, sliding along any surface in the way (trying each
// axis alone when the straight step is blocked). Reports whether it's done: caught, or wedged with nowhere to go,
// which breaks it off with a burst.
static bool boomerang_return(Sim& sim, Fighter& fighter, Attack& attack)
{
Vec2 toHand = fighter.hand_get() - attack.position;
float distance = vec2_length(toHand);
if (distance < CATCH_DISTANCE)
return true;
// Small steps, so it can't skip through a thin platform either.
float travel = std::min(distance, attack.stats.speed * TICK);
int steps = travel / BOOMERANG_STEP + 1;
Vec2 step = vec2_normalize(toHand) * (travel / steps);
for (int i = 0; i < steps; i++)
{
bool isMoved = false;
for (Vec2 tried : {step, Vec2{step.x, 0}, Vec2{0, step.y}})
{
Vec2 next = attack.position + tried;
if ((tried.x == 0 && tried.y == 0) || sim.world.is_blocking_at(next))
continue;
attack.position = next;
isMoved = true;
break;
}
if (isMoved)
continue;
sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner);
return true;
}
return false;
}
// Bounces a direction off the wall between from (open) and to (solid), flipping whichever axis crossed into it, or
// mirroring it off a ramp's surface.
static Vec2 reflect_get(Sim& sim, Vec2 from, Vec2 to, Vec2 direction)
{
Vec2 normal = sim.world.normal_get(to);
if (normal.x != 0 || normal.y != 0)
return direction - normal * (2 * vec2_dot(direction, normal));
bool isWallX = sim.world.is_blocking_at({to.x, from.y});
bool isWallY = sim.world.is_blocking_at({from.x, to.y});
if (!isWallX && !isWallY)
{
isWallX = true;
isWallY = true;
}
return {isWallX ? -direction.x : direction.x, isWallY ? -direction.y : direction.y};
}
// Plays the sound of a projectile striking a wall or a target. Hooks latch and rockets and fireballs explode
// with their own sounds.
static void impact_play(Sim& sim, AttackKind kind)
{
switch (kind)
{
case ATTACK_ARROW:
sim.cues->sound_play(SOUND_IMPACT_ARROW);
break;
case ATTACK_BOOMERANG:
sim.cues->sound_play(SOUND_IMPACT_BOOMERANG);
break;
case ATTACK_BULLET:
sim.cues->sound_play(SOUND_IMPACT_BULLET);
break;
case ATTACK_FLAIL:
sim.cues->sound_play(SOUND_FLAIL_LAND);
break;
case ATTACK_MINE:
sim.cues->sound_play(SOUND_IMPACT_MINE);
break;
case ATTACK_PROJECTILE:
sim.cues->sound_play(SOUND_IMPACT_BLASTER);
break;
default:
break;
}
}
// Turns a projectile off the wall it's about to enter. Each ricochet doubles its later damage.
static void ricochet(Sim& sim, Attack& attack, Vec2 next)
{
attack.velocity = reflect_get(sim, attack.position, next, attack.velocity);
attack.angle = vec2_angle(attack.velocity);
attack.bounces--;
attack.ricochets++;
sim.cues->sound_play(SOUND_RICOCHET);
sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner);
}
// Walks the capsule in small steps, writing the first point that touches the rect.
static bool is_capsule_in_rect(Capsule capsule, Rect rect, Vec2& point)
{
Vec2 delta = capsule.to - capsule.from;
int steps = vec2_length(delta) / CAPSULE_STEP + 1;
for (int i = 0; i <= steps; i++)
{
Vec2 sample = capsule.from + delta * ((float)i / steps);
Vec2 closest = {std::clamp(sample.x, rect.x, rect.x + rect.w), std::clamp(sample.y, rect.y, rect.y + rect.h)};
if (vec2_length(sample - closest) > capsule.radius)
continue;
point = closest;
return true;
}
return false;
}
static bool is_falloff(AttackKind kind)
{
return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET ||
kind == ATTACK_FIREBALL || kind == ATTACK_BEAM || kind == ATTACK_HOOK || kind == ATTACK_WISP;
}
// Ranged shots lose damage linearly with distance flown: full at the muzzle, down to a floor far out. A beam's
// distance runs along it to the point, a hook's is how far it reached from where it was thrown, and a wisp's counts
// only from when it set off (not its orbiting).
float falloff_at(float distance)
{
return std::max(FALLOFF_FLOOR, 1 - distance / FALLOFF_RANGE);
}
static float falloff_get(Attack& attack, Vec2 point)
{
AttackKind kind = attack.def()->kind;
if (!is_falloff(kind))
return 1;
float distance = attack.travelled + (kind == ATTACK_BEAM ? vec2_length(point - attack.position) : 0);
if (kind == ATTACK_HOOK)
distance = vec2_length(attack.position - attack.origin);
if (kind == ATTACK_WISP)
distance = attack.length;
return falloff_at(distance);
}
// How far a rocket, mine or fireball's explosion reaches. It grows with trinkets but not with the attack's own size, so
// a big fireball keeps its listed area.
float blast_area_get(Attack& attack)
{
return attack.stats.area * attack.scale / attack.stats.scale;
}
// What ricochets and falloff do to an attack's damage at a point.
static float multiplier_get(Attack& attack, Vec2 point)
{
return (1 + attack.ricochets * RICOCHET_BONUS) * falloff_get(attack, point);
}
// How hard an attack hits right now against its plain damage, with its speed bonus, ricochets and falloff.
float strength_get(Attack& attack, Vec2 point)
{
return attack.bonus * multiplier_get(attack, point);
}
// How much of an attack's black fill its colour takes over, from none at plain damage to all at FILL_STRENGTH.
float fill_get(float strength)
{
return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f);
}
// How much of a hit's knockback an attack delivers: rapid shots and beams barely push, arrows and hooks a little,
// and melee and explosions in full.
static float knock_get(AttackKind kind)
{
if (kind == ATTACK_BULLET || kind == ATTACK_PROJECTILE || kind == ATTACK_BEAM)
return KNOCK_SLIGHT;
if (kind == ATTACK_ARROW || kind == ATTACK_HOOK)
return KNOCK_LIGHT;
return 1;
}
// A plain lash rolling out: straight from the handle to its front, round a loop that shrinks as it goes, then the rest
// of the whip doubled back above, which starts out behind the fighter. It lies straight once it has cracked.
std::vector<Vec2> lash_get(Attack& attack)
{
Vec2 direction = vec2_polar(attack.angle, 1);
Vec2 up = attack.isLeft ? Vec2{-direction.y, direction.x} : Vec2{direction.y, -direction.x};
float front = vec2_length(attack.tip - attack.position);
float unfurl = attack.length > 0 ? std::min(1.0f, front / attack.length) : 1;
float radius = WHIP_LOOP * (1 - unfurl) * attack.scale;
std::vector<Vec2> points = {attack.position, attack.tip};
if (radius < 1)
return points;
Vec2 center = attack.tip + up * radius;
float start = vec2_angle(up * -1);
float turn = vec2_dot(Vec2{up.y, -up.x}, direction) > 0 ? 1 : -1;
for (int i = 1; i <= WHIP_LOOP_STEPS; i++)
points.push_back(center + vec2_polar(start + turn * PI * i / WHIP_LOOP_STEPS, radius));
points.push_back(attack.tip + up * (radius * 2) - direction * (attack.length - front));
return points;
}
// A fighter other than the attacker that's still alive, any dummy, or a block that isn't broken.
// Whether one fighter's attacks can touch another: always in a match, but in the sandbox only between two fighters that
// both have PvP on.
static bool is_hostile(Sim& sim, int owner, int target)
{
Match& match = sim.match;
return match.state != MATCH_PRACTICE || (sim.fighters[owner].isPvp && sim.fighters[target].isPvp);
}
static bool is_target(Sim& sim, int target, int owner)
{
if (target >= MAX_FIGHTERS)
{
World& world = sim.world;
int index = target - MAX_FIGHTERS;
return index < (int)world.dummies.size() &&
(world.dummies[index].tile < 0 || world.tiles[world.dummies[index].tile] == TILE_BLOCK);
}
return target < sim.fighterCount && target != owner && sim.fighters[target].isAlive &&
is_hostile(sim, owner, target);
}
static Rect target_box(Sim& sim, int target)
{
return target < MAX_FIGHTERS ? sim.fighters[target].body.box : sim.world.dummies[target - MAX_FIGHTERS].box;
}
// Numbers part way from a level's normal attack to its charged one, damage kept to steps of 5.
static AttackStats stats_blend(const LevelDef& level, float charge)
{
float damage = math_lerp(level.normal.damage, level.charged.damage, charge);
return {
.damage = std::round(damage / DAMAGE_STEP) * DAMAGE_STEP,
.range = math_lerp(level.normal.range, level.charged.range, charge),
.speed = math_lerp(level.normal.speed, level.charged.speed, charge),
.volley = level.normal.volley,
};
}
// How far a Splitter copy turns from the aim. Copies sit a fixed gap apart, so the fan widens as the Splitter adds
// copies: wide gaps for melee, narrower for thrown weapons, tight for shots, all narrowed by spread (Diazepam). Spins
// share the circle evenly.
static float split_offset(AttackKind kind, int split, int splits, float spread)
{
if (kind == ATTACK_SPIN)
return 2 * PI * split / splits;
float gap = SPLIT_GAP;
if (kind == ATTACK_SWING || kind == ATTACK_SLASH || kind == ATTACK_POKE || kind == ATTACK_PUNCH ||
kind == ATTACK_UPPERCUT)
gap = SPLIT_GAP_MELEE;
if (kind == ATTACK_BOOMERANG || kind == ATTACK_FLAIL || kind == ATTACK_MINE)
gap = SPLIT_GAP_THROWN;
return gap * (split - (splits - 1) / 2.0f) * spread;
}
// Walks from start along angle until range runs out or isBlocked passes, returning where it stopped.
static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked)
{
Vec2 point = start;
while (vec2_length(point - start) < range && !isBlocked(point))
point = point + vec2_polar(angle, RAY_STEP);
return point;
}
// The capsules an attack hits with right now, returning how many. A plain whip fans rays across its cone from the
// hand, out to its unfurled front and short of walls. Everything else is one capsule from position to tip.
int shapes_get(Sim& sim, Attack& attack, Capsule* shapes)
{
if (attack.def()->kind != ATTACK_WHIP || attack.isCharged)
{
shapes[0] = {attack.position, attack.tip, attack.radius};
return 1;
}
auto isBlocked = [&](Vec2 point) {
return sim.world.is_blocking_at(point);
};
float front = vec2_length(attack.tip - attack.origin);
for (int ray = 0; ray < WHIP_RAYS; ray++)
{
float angle = attack.aimAngle + WHIP_CONE * (2.0f * ray / (WHIP_RAYS - 1) - 1);
shapes[ray] = {attack.origin, ray_march(attack.origin, angle, front, isBlocked), attack.radius};
}
return WHIP_RAYS;
}
// Fires the weapon in hand with the given charge if it's ready. Full charge uses the charged attack.
// With the Doubler on, the same attack repeats a moment later for a fraction of the damage (stacked Doublers each
// add a repeat of their own, one after another). With Dual Wield on, an off-hand weapon per copy fires too (see
// Fighter::off_slots_get), each for a fraction of its damage. Every weapon (see Fighter::weapon_key) keeps its own
// cooldown, in any hand, so swapping to another weapon never inherits the last one's wait.
void Weapons::fire(Sim& sim, Fighter& fighter, float charge)
{
int held = fighter.weapon_key(fighter.slot);
float gloves = fighter.trinket_get(TRINKET_GLOVES, 1);
if (held >= 0 && fighter.cooldowns[held] <= 0 && !is_busy(fighter, held))
{
bool isSpawned = is_spawned(sim, fighter, {.charge = charge, .weaponSlot = held});
fighter.cooldowns[held] = fighter.level_get(held).cooldown / gloves;
// Each Doubler copy adds an echo, at its own level's power.
for (int i = 0, repeat = 0; isSpawned && i < fighter.trinketSlots; i++)
{
if (fighter.trinketLoadout[i] != TRINKET_DOUBLER)
continue;
float power = TRINKET_DEFS[TRINKET_DOUBLER].values[fighter.trinketSlotLevels[i]];
fighter.echoes.push_back({charge, DOUBLER_DELAY * ++repeat, power, held});
}
}
// Each Dual Wield copy fires one off-hand weapon at its own level's cut, the strongest cut going to the first.
float powers[SLOT_MAX];
int copies = 0;
for (int i = 0; i < fighter.trinketSlots; i++)
if (fighter.trinketLoadout[i] == TRINKET_DUAL)
powers[copies++] = TRINKET_DEFS[TRINKET_DUAL].values[fighter.trinketSlotLevels[i]];
std::sort(powers, powers + copies, std::greater<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;
is_spawned(sim, fighter,
{.charge = charge, .power = powers[hand - 1], .weaponSlot = off, .hand = hand, .isSecondary = true});
fighter.cooldowns[off] = fighter.level_get(off).cooldown / gloves;
}
}
// Whether an attack flies on its own, which the Accelerator speeds up.
static bool is_projectile(AttackKind kind, bool isCharged)
{
return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET ||
kind == ATTACK_BOOMERANG || kind == ATTACK_FLAIL || kind == ATTACK_FLAME || kind == ATTACK_FIREBALL ||
kind == ATTACK_WISP || (kind == ATTACK_HOOK && !isCharged);
}
// Whether Ricochet lets an attack ricochet off walls.
static bool is_bouncy(AttackKind kind)
{
return kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ARROW || kind == ATTACK_ROCKET ||
kind == ATTACK_BEAM || kind == ATTACK_WISP;
}
// Shots flying on their own velocity gain the part of the fighter's velocity heading along the aim, so they still fly
// where aimed and running away never slows them. Boomerangs fly their own path, and wisps set off from a standstill.
static bool is_inheriting(AttackKind kind, bool isCharged)
{
return is_projectile(kind, isCharged) && kind != ATTACK_BOOMERANG && kind != ATTACK_WISP;
}
// Whether the fighter's box would fit with its middle at centre, clear of walls (and inside the map).
static bool is_box_clear(Sim& sim, Fighter& fighter, Vec2 centre)
{
Rect box = fighter.body.box;
int columns = (int)(box.w / TELEPORT_PROBE) + 1;
int rows = (int)(box.h / TELEPORT_PROBE) + 1;
for (int row = 0; row <= rows; row++)
for (int column = 0; column <= columns; column++)
{
Vec2 point = {centre.x - box.w / 2 + box.w * column / columns, centre.y - box.h / 2 + box.h * row / rows};
if (sim.world.is_blocking_at(point))
return false;
}
return true;
}
// Where a teleport toward the cursor lands: the cursor, held within range of the fighter's middle, backed off toward
// the fighter until its box fits there. Returns the fighter's own middle if nowhere fits.
static Vec2 teleport_spot(Sim& sim, Fighter& fighter, Vec2 target, float range)
{
Vec2 centre = fighter.center_get();
Vec2 offset = target - centre;
float distance = std::min(range, vec2_length(offset));
Vec2 direction = vec2_normalize(offset);
for (float along = distance; along > 0; along -= TELEPORT_STEP)
{
Vec2 spot = centre + direction * along;
if (is_box_clear(sim, fighter, spot))
return spot;
}
return centre;
}
// The fighter's speed along the aim that an inheriting shot gains. Launched fighters lend nothing.
static float push_get(Fighter& fighter, Vec2 aim)
{
return fighter.isLaunched ? 0 : std::max(0.0f, vec2_dot(fighter.body.velocity, aim));
}
// The damage multiplier a fighter's speed gives an attack fired now along aim, with the Speedometer adding to it.
// Inheriting shots count only their push, everything else the fighter's whole speed.
static float bonus_get(Fighter& fighter, AttackKind kind, bool isCharged, Vec2 aim)
{
float speed = is_inheriting(kind, isCharged) ? push_get(fighter, aim) : vec2_length(fighter.body.velocity);
return 1 + (SPEED_DAMAGE + fighter.trinket_get(TRINKET_SPEEDOMETER, 0)) * speed / SPEED_UNIT;
}
// How much a fighter's colour fills in from the bonus its held weapon would get firing right now.
float Weapons::buff_get(Fighter& fighter)
{
int held = fighter.weapon_get();
if (held < 0)
return 0;
bool isCharged = fighter.charge >= 1;
AttackKind kind = isCharged ? WEAPON_DEFS[held].charged.kind : WEAPON_DEFS[held].normal.kind;
return fill_get(bonus_get(fighter, kind, isCharged, fighter.aim));
}
// What an attack does for this fighter, mirroring is_spawned and damage_deal: Splitter copies hit for less and come
// out smaller, Embiggener adds damage, Gloves and Energy Drink speed up attacking and charging, Enlarger grows blasts
// and melee reach, the Accelerator speeds up projectiles, Diazepam tightens spread and Shock Absorber eases lunges.
// "Every" is the cooldown, or for a charged attack the time to charge.
AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, bool isCharged)
{
const WeaponDef& def = WEAPON_DEFS[weapon];
const AttackDef& attack = isCharged ? def.charged : def.normal;
const LevelDef& levelDef = def.levels[level];
AttackStats stats = isCharged ? levelDef.charged : levelDef.normal;
AttackKind kind = attack.kind;
bool isTeleport = kind == ATTACK_TELEPORT;
int splits = isTeleport ? 1 : (int)fighter.trinket_get(TRINKET_SPLITTER, 1);
float splitScale = splits > 1 ? SPLIT_SCALE : 1;
float embiggen = 1 + (fighter.trinket_get(TRINKET_EMBIGGENER, 1) - 1) * EMBIGGEN_DAMAGE;
float enlarger = fighter.trinket_get(TRINKET_ENLARGER, 1);
float gloves = fighter.trinket_get(TRINKET_GLOVES, 1);
float drink = fighter.trinket_get(TRINKET_DRINK, 1);
float accelerator = is_projectile(kind, isCharged) ? fighter.trinket_get(TRINKET_ACCELERATOR, 1) : 1;
float steadiness = 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0);
float absorb = fighter.trinket_get(TRINKET_ABSORBER, 1);
bool isMelee = kind == ATTACK_SWING || kind == ATTACK_SLASH;
AttackSummary summary;
summary.isDamaging = kind != ATTACK_DETONATE && stats.damage > 0;
float damage = splits > 1 ? std::max(1.0f, std::round(stats.damage * SPLIT_DAMAGE)) : stats.damage;
summary.damage = std::round(damage * embiggen);
summary.isDamageChanged = summary.damage != stats.damage;
summary.every = isCharged ? CHARGE_TIME / drink : levelDef.cooldown / gloves;
summary.isEveryChanged = isCharged ? drink != 1 : gloves != 1;
summary.range = stats.range * (isMelee ? enlarger * splitScale : 1);
summary.isRangeChanged = summary.range != stats.range;
summary.area = stats.area * attack.scale * enlarger * splitScale;
summary.isAreaChanged = enlarger != 1 || splits > 1;
summary.speed = is_projectile(kind, isCharged) ? stats.speed * accelerator : 0;
summary.isSpeedChanged = accelerator != 1;
summary.spread = attack.spread * steadiness;
summary.isSpreadChanged = steadiness != 1;
summary.lunge = stats.lunge * absorb;
summary.isLungeChanged = absorb != 1;
summary.pieces = stats.volley * splits;
summary.isPiecesChanged = splits > 1;
// A detonator only sets off what's already out: one press, nothing of its own.
if (kind == ATTACK_DETONATE)
{
summary.range = summary.area = summary.speed = 0;
summary.pieces = 1;
summary.isPiecesChanged = false;
}
if (levelDef.limit > 0 && !isCharged)
summary.out = levelDef.limit;
else if (kind == ATTACK_BOOMERANG || kind == ATTACK_MINE)
summary.out = levelDef.count;
return summary;
}
// Builds and launches a weapon's attack, reporting whether anything fired. Every attack leaves a little off the aim, by
// as much as its spread, which Diazepam steadies. Every fighter hits harder the faster it's moving (or, for
// shots, the faster it moves along the aim), and the Speedometer adds to that. Trinkets speed up projectiles and grow
// attacks here. Detonating sets off the weapon's mines instead, and mines won't place past the level's count. A level
// with a limit fires only as many pieces as fit under it (see LevelDef).
bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot)
{
int key = shot.weaponSlot;
int held = fighter.weaponLoadout[key];
float charge = shot.charge;
float power = shot.power;
const WeaponDef& weapon = WEAPON_DEFS[held];
const LevelDef& level = fighter.level_get(key);
bool isCharged = charge >= 1;
const AttackDef& def = isCharged ? weapon.charged : weapon.normal;
AttackStats stats = isCharged ? level.charged : level.normal;
if (weapon.isDrawn)
stats = stats_blend(level, charge);
AttackKind kind = def.kind;
int mines = 0;
for (Attack& attack : attacks)
mines += attack.def()->kind == ATTACK_MINE && attack.owner == fighter.index && attack.key == key &&
!attack.isDead && !attack.isCopy;
if (kind == ATTACK_MINE && !shot.isEcho && mines >= level.count)
return false;
if (level.limit > 0 && !shot.isSecondary)
{
int out = 0;
for (Attack& attack : attacks)
out += attack.def() == &def && attack.owner == fighter.index && attack.key == key && !attack.isCopy &&
!attack.isSecondary && !attack.isDead;
if (out >= level.limit)
return false;
stats.volley = std::min(stats.volley, level.limit - out);
}
if (!shot.isSecondary)
{
fighter.kickFrom = weapon.kick;
fighter.kickTo = weapon.follow;
fighter.kickTime = 0;
}
if (kind == ATTACK_DETONATE)
{
// With nothing armed to set off, the detonator just clicks.
bool isLive = false;
for (Attack& attack : attacks)
{
if (!is_mine_armed(attack) || attack.owner != fighter.index || attack.key != key || attack.isDead)
continue;
attack_explode(sim, attack);
isLive = true;
}
sim.cues->sound_play(isLive ? def.sound : SOUND_DETONATE_EMPTY);
return true;
}
if (is_projectile(kind, isCharged))
stats.speed *= fighter.trinket_get(TRINKET_ACCELERATOR, 1);
float spread = 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0);
float jitter = def.spread * DEGREE * spread;
Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.rng.random_get(-jitter, jitter), 1);
float push = is_inheriting(kind, isCharged) ? push_get(fighter, aim) : 0;
// A part-charged normal attack (not a drawn one, whose draw already strengthens it) adds to the speed bonus, so it
// hits harder and fills in, glows and shakes the same way.
float bonus = bonus_get(fighter, kind, isCharged, aim);
if (!isCharged && !weapon.isDrawn)
bonus *= 1 + charge * PARTIAL_CHARGE_BONUS;
stats.damage = std::max(1.0f, std::round(stats.damage * power * bonus));
Attack attack = {
.stats = stats,
.origin = fighter.hand_get(shot.hand),
.velocity = aim * stats.speed,
.aimAngle = vec2_angle(aim),
.angle = vec2_angle(aim),
.bonus = bonus,
.duration = stats.duration > 0 ? stats.duration : def.duration,
.scale = def.scale * stats.scale,
.bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0,
.hitsLeft = def.hits,
.owner = fighter.index,
.weapon = held,
.key = key,
.hand = shot.hand,
.isCharged = isCharged,
.isLeft = fighter.is_left(),
.isSecondary = shot.isSecondary,
};
attack.position = attack.origin + aim * MUZZLE_DISTANCE;
// A teleport is one spell however many copies anything else would make: it marks where it will land.
if (kind == ATTACK_TELEPORT)
{
attack.position = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range);
attack.origin = fighter.center_get();
attack.tip = attack.position;
attack.hitsLeft = 0;
attack.scale *= fighter.trinket_get(TRINKET_ENLARGER, 1);
attack.isCopy = shot.isEcho;
attack_remember(attack);
attacks.push_back(attack);
sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound);
return true;
}
if (kind == ATTACK_UPPERCUT)
attack.angle -= (fighter.is_left() ? -1 : 1) * PI / 2;
if (kind == ATTACK_SWING)
attack.scale = stats.range / HAMMER_REACH;
if (kind == ATTACK_SLASH)
attack.scale = stats.range / SLASH_REACH;
attack.scale *= fighter.trinket_get(TRINKET_ENLARGER, 1);
if (kind == ATTACK_POKE)
attack.duration = std::max(TICK, stats.range - LANCE_LENGTH * attack.scale) / stats.speed / POKE_THRUST;
int splits = fighter.trinket_get(TRINKET_SPLITTER, 1);
if (splits > 1)
{
attack.scale *= SPLIT_SCALE;
attack.stats.damage = std::max(1.0f, std::round(attack.stats.damage * SPLIT_DAMAGE));
}
// One piece per volley shot per split, each fanned around the aim (or set side by side when the volley has no
// fan). The centre split stays primary and the rest are secondary.
for (int v = 0; v < stats.volley; v++)
{
float place = stats.volley > 1 ? (float)v / (stats.volley - 1) - 0.5f : 0;
for (int split = 0; split < splits; split++)
{
float offset = 0;
if (stats.volley > 1)
offset += (stats.fan * place + sim.rng.random_get(-def.scatter, def.scatter) * DEGREE) * spread;
if (splits > 1)
offset += split_offset(kind, split, splits, spread);
Attack piece = attack;
if (stats.fan <= 0)
piece.origin =
piece.origin + vec2_polar(attack.angle + PI / 2, VOLLEY_GAP * (stats.volley - 1) * place);
piece.angle += offset;
piece.aimAngle += offset;
piece.velocity = vec2_polar(piece.angle, stats.speed + push);
piece.position = piece.origin + vec2_polar(piece.angle, MUZZLE_DISTANCE);
piece.isSecondary |= split != splits / 2;
piece.isCopy = shot.isEcho || split != splits / 2;
if (kind == ATTACK_WISP)
{
// Wisps rise out of the fighter's middle to join its orbit (see orbits_update). Cast with attack held
// they gather there until it's let go (see wisps_release); otherwise each sets off a little after the
// last.
int index = v * splits + split;
piece.isGathering = fighter.is_down(CONTROL_ATTACK);
piece.slot = index;
piece.duration = attack.duration + index * WISP_LAUNCH_STAGGER;
piece.position = fighter.center_get();
piece.orbit = {};
piece.velocity = {};
// Until it sets off, its angle is only how far its spread (and any split) turns it off the aim.
piece.angle = std::remainder(piece.aimAngle - vec2_angle(fighter.aim), 2 * PI);
}
piece_launch(sim, piece);
attacks.push_back(piece);
}
}
sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound);
Vec2 muzzle = attack.origin + aim * MUZZLE_DISTANCE;
float side = fighter.is_left() ? -1 : 1;
if (weapon.flash > 0)
sim.cues->flash_spawn(muzzle, vec2_angle(aim), weapon.flash, fighter.index);
if (weapon.isEjecting)
sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK),
fighter.index);
if (isCharged)
sim.cues->shake_add(def.shake * FIRE_SHAKE);
// A fireball goes off with an airblast at the muzzle, as wide as the fireball's own blast.
if (kind == ATTACK_FIREBALL)
blast_spawn(sim, {.position = muzzle,
.area = stats.area * fighter.trinket_get(TRINKET_ENLARGER, 1),
.owner = fighter.index,
.isHarmless = true});
// An uppercut launches its fighter the way it rises, cancelling any speed against that first. Shock Absorber
// softens every push the shot gives its own fighter: lunges, kickback and Recoil Coil.
float absorb = fighter.trinket_get(TRINKET_ABSORBER, 1);
Vec2 lunge = kind == ATTACK_UPPERCUT ? vec2_polar(attack.angle, 1) : aim;
if (kind == ATTACK_UPPERCUT)
fighter.body.velocity = fighter.body.velocity - lunge * std::min(0.0f, vec2_dot(fighter.body.velocity, lunge));
if (stats.lunge > 0)
fighter.burst_add(lunge * stats.lunge * absorb);
if (stats.lunge < 0)
fighter.impulse_add(aim * stats.lunge * absorb);
float recoil =
fighter.trinket_get(TRINKET_RECOIL, 0) * math_pow(stats.damage * stats.volley, RECOIL_CURVE) * absorb;
if (recoil > 0)
fighter.impulse_add(fighter.aim * -recoil);
return true;
}
void Weapons::update(Sim& sim)
{
for (int i = 0; i < sim.fighterCount; i++)
contact_update(sim, sim.fighters[i]);
for (int i = 0; i < sim.fighterCount; i++)
{
Fighter& fighter = sim.fighters[i];
if (fighter.hitstop > 0)
continue;
if (fighter.isAlive)
sim.cues->fighter_aura_spawn(fighter);
for (Echo& echo : fighter.echoes)
{
echo.timer -= TICK;
if (echo.timer <= 0 && echo.weaponSlot == fighter.weapon_key(fighter.slot))
is_spawned(sim, fighter,
{
.charge = echo.charge,
.power = echo.power,
.weaponSlot = echo.weaponSlot,
.isEcho = true,
.isSecondary = true,
});
}
fighter.echoes.erase_if([](Echo& echo) { return echo.timer <= 0; });
}
orbits_update(sim);
// A fighter's attacks freeze with it in hitstop.
for (Attack& attack : attacks)
{
if (sim.fighters[attack.owner].hitstop > 0)
{
attack_remember(attack);
continue;
}
bool isNew = attack.time == 0;
attack_remember(attack);
attack_move(sim, attack);
if (is_falloff(attack.def()->kind))
attack.travelled += vec2_length(attack.position - attack.positionPrevious);
if (isNew)
attack_remember(attack);
attack.hitCooldown -= TICK;
AttackKind kind = attack.def()->kind;
bool isResting = (kind == ATTACK_MINE && attack.time >= attack.duration) || kind == ATTACK_FLAME;
bool isLine = kind == ATTACK_BEAM || kind == ATTACK_WHIP;
if (!isLine && kind != ATTACK_EXPLOSION && !attack.isAttached && !isResting)
sim.cues->attack_afterimage_spawn(attack);
if (!attack.isAttached)
sim.cues->attack_aura_spawn(attack);
// Dummies and blocks are checked before fighters, so racers crowding a block can't soak up every swing at it.
Capsule shapes[WHIP_RAYS];
int shapeCount = shapes_get(sim, attack, shapes);
int targets = MAX_FIGHTERS + sim.world.dummies.size();
for (int i = 0; i < targets; i++)
{
int target = (i + MAX_FIGHTERS) % targets;
if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || !is_target(sim, target, attack.owner))
continue;
for (int shape = 0; shape < shapeCount; shape++)
{
Vec2 point;
if (!is_capsule_in_rect(shapes[shape], target_box(sim, target), point))
continue;
attack_hit(sim, attack, target, point);
break;
}
}
attack.time += TICK;
}
tesla_update(sim);
attacks_cap(sim);
std::erase_if(attacks, [](Attack& attack) { return attack.isDead; });
attacks.insert(attacks.end(), pending.begin(), pending.end());
pending.clear();
}
// No fighter keeps more than ATTACKS_MAX attacks out: past that, their oldest loose ones (anything not in or tethered
// to a hand and not an explosion going off, so mines, stuck arrows, wisps and flames) fizzle out with a puff, oldest
// first. Attacks are kept in the order they were made, so the first found are the oldest.
void Weapons::attacks_cap(Sim& sim)
{
for (int i = 0; i < sim.fighterCount; i++)
{
int count = 0;
for (Attack& attack : attacks)
count += attack.owner == i && !attack.isDead;
for (Attack& attack : attacks)
{
if (count <= ATTACKS_MAX)
break;
AttackKind kind = attack.def()->kind;
bool isHeld = kind == ATTACK_SWING || kind == ATTACK_POKE || kind == ATTACK_SPIN || kind == ATTACK_FLAIL ||
kind == ATTACK_WHIP || kind == ATTACK_BEAM || kind == ATTACK_HOOK || kind == ATTACK_EXPLOSION;
if (attack.owner != i || attack.isDead || isHeld)
continue;
attack.isDead = true;
count--;
sim.cues->attack_burst_spawn(SMOKE_BURST, attack.position, 0, attack);
}
}
}
// Advances an attack by one tick according to its kind and updates its hit capsule.
void Weapons::attack_move(Sim& sim, Attack& attack)
{
const AttackDef& def = *attack.def();
Fighter& fighter = sim.fighters[attack.owner];
float progress = attack.time / attack.duration;
float range = attack.stats.range;
switch (def.kind)
{
case ATTACK_SWING:
{
// The overhead arc is laid out around the aim, mirrored when facing left.
float swing = SWING_START + (SWING_END - SWING_START) * std::min(1.0f, progress * progress);
float side = attack.isLeft ? -1 : 1;
attack.angle = attack.aimAngle + swing * side;
attack.origin = fighter.hand_get();
attack.position = attack.origin + vec2_polar(attack.angle, HAMMER_HEAD.x * attack.scale) +
vec2_polar(attack.angle + PI / 2, HAMMER_HEAD.y * attack.scale * side);
attack.radius = HAMMER_HEAD_RADIUS * attack.scale;
// A head striking a wall stops the swinger's motion along the aim and boosts them back against it (aiming
// down at the floor pops them up), once per swing.
if (!attack.isRebounded && sim.world.is_blocking_at(attack.position))
{
Vec2 away = vec2_polar(attack.aimAngle + PI, 1);
float into = vec2_dot(fighter.body.velocity, away);
fighter.body.velocity = fighter.body.velocity - away * std::min(0.0f, into);
fighter.impulse_add(away * HAMMER_REBOUND * (attack.isCharged ? HAMMER_REBOUND_CHARGED : 1) *
fighter.trinket_get(TRINKET_ABSORBER, 1));
attack.isRebounded = true;
sim.cues->sound_play(SOUND_BOUNCE);
sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner);
}
if (progress < 1)
break;
attack.isDead = true;
if (!attack.isCharged)
break;
attack.radius = range * SLAM_RATIO;
sim.cues->sound_play(SOUND_SLAM);
sim.cues->shake_add(def.shake);
sim.cues->burst_spawn(SLAM_BURST, attack.position, 0, attack.owner);
break;
}
case ATTACK_SLASH:
attack.position = attack.origin + vec2_polar(attack.angle, SLASH_DISTANCE * attack.scale + SLASH_GAP) +
attack.velocity * attack.time;
attack.radius = SLASH_RADIUS * attack.scale;
attack.isDead = progress >= 1;
break;
case ATTACK_PUNCH:
attack.origin = fighter.hand_get();
attack.position = attack.origin + vec2_polar(attack.angle, range * std::min(1.0f, progress / PUNCH_THRUST));
attack.radius = PUNCH_RADIUS * attack.scale;
attack.isDead = progress >= 1;
break;
case ATTACK_UPPERCUT:
{
// The fist rises beside its fighter on the aimed side, carried up with them. Splitter's smaller copies
// keep the full reach so they fan out around the fighter instead of piling up on it.
float reach = UPPERCUT_REACH * attack.scale;
if (fighter.trinket_get(TRINKET_SPLITTER, 1) > 1)
reach /= SPLIT_SCALE;
attack.origin = fighter.center_get();
attack.position = attack.origin + vec2_polar(attack.aimAngle, reach);
attack.radius = UPPERCUT_RADIUS * attack.scale;
attack.isDead = progress >= 1;
break;
}
case ATTACK_POKE:
{
float extend = progress < POKE_THRUST ? progress / POKE_THRUST : (1 - progress) / (1 - POKE_THRUST);
attack.origin = fighter.hand_get();
attack.position = attack.origin +
vec2_polar(attack.angle, std::max(0.0f, extend) * (range - LANCE_LENGTH * attack.scale));
attack.tip = attack.position + vec2_polar(attack.angle, LANCE_LENGTH * attack.scale);
attack.radius = LANCE_RADIUS * attack.scale;
attack.isDead = progress >= 1;
if (attack.isCharged && progress < POKE_THRUST)
sim.cues->burst_spawn(TRAIL_BURST, attack.tip, attack.angle + PI, attack.owner);
break;
}
case ATTACK_ARROW:
if (attack.isAttached)
{
attack.isDead = attack.time >= attack.duration;
break;
}
attack.velocity.y += ARROW_GRAVITY * TICK;
[[fallthrough]];
case ATTACK_BULLET:
attack.angle = vec2_angle(attack.velocity);
[[fallthrough]];
case ATTACK_PROJECTILE:
{
// Small steps so fast shots can't skip through thin walls. The hit capsule spans the whole tick's
// path so they can't skip past targets either.
Vec2 start = attack.position;
int steps = vec2_length(attack.velocity) * TICK / PROJECTILE_STEP + 1;
attack.radius = (def.kind == ATTACK_BULLET ? SLUG_RADIUS
: def.kind == ATTACK_ARROW ? ARROW_RADIUS
: BULLET_RADIUS) *
attack.scale;
for (int step = 0; step < steps && !attack.isDead; step++)
{
Vec2 next = attack.position + attack.velocity * (TICK / steps);
bool isWall = sim.world.is_blocking_at(next);
if (isWall && attack.bounces > 0)
{
ricochet(sim, attack, next);
continue;
}
// Arrows stick where they land and linger, harmless, as footholds for their archer. Blocks take the hit
// instead.
if (isWall && def.kind == ATTACK_ARROW && sim.world.tile_at(next) != TILE_BLOCK)
{
attack.isAttached = true;
attack.hitsLeft = 0;
attack.velocity = {};
attack.duration = attack.time + ARROW_LINGER;
impact_play(sim, def.kind);
sim.cues->burst_spawn(IMPACT_BURST, next, 0, attack.owner);
break;
}
attack.position = next;
bool isBlocked = sim.world.is_blocking_at(attack.position);
if (!isBlocked && vec2_length(attack.position - attack.origin) < range)
continue;
attack.isDead = true;
if (isBlocked)
impact_play(sim, def.kind);
sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner);
}
attack.tip = start;
break;
}
case ATTACK_ROCKET:
{
float speed = std::min(attack.stats.speed * ROCKET_SPEEDUP,
vec2_length(attack.velocity) + ROCKET_ACCELERATION * TICK);
attack.velocity = vec2_polar(attack.angle, speed);
attack.radius = ROCKET_RADIUS * attack.scale;
Vec2 next = attack.position + attack.velocity * TICK;
if (sim.world.is_blocking_at(next) && attack.bounces > 0)
{
ricochet(sim, attack, next);
break;
}
attack.position = next;
sim.cues->burst_spawn(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL),
attack.angle + PI, attack.owner);
bool isSpent = attack.time > ROCKET_LIFETIME || vec2_length(attack.position - attack.origin) > range;
if (sim.world.is_blocking_at(attack.position) || isSpent)
attack_explode(sim, attack);
break;
}
case ATTACK_WISP:
wisp_move(sim, attack);
break;
case ATTACK_TELEPORT:
teleport_move(sim, attack);
break;
case ATTACK_DETONATE:
break;
case ATTACK_EXPLOSION:
attack.radius = range;
attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft;
attack.isDead = progress >= 1;
break;
case ATTACK_BEAM:
// A beam hits only as it's fired, then fades where it was: its first stretch stays strung from the hand
// to where it struck, and each stretch it ricocheted along stays where it bounced.
attack.radius = BEAM_RADIUS * attack.scale;
attack.isDead = progress >= 1;
attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft;
if (attack.ricochets == 0)
{
attack.origin = fighter.hand_get(attack.hand);
attack.angle = vec2_angle(attack.tip - attack.origin);
attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE);
}
break;
case ATTACK_WHIP:
{
// A charged crack stays strung from the end of the handle in the hand to where it cracked, hitting at
// once. A plain lash rolls out from the handle, going with the hand, sweeping down across its cone from
// the upper edge, and hits anywhere in the cone out to its front until it cracks at full reach (short of
// any wall).
attack.origin = fighter.hand_get(attack.hand);
attack.radius = BEAM_RADIUS * attack.scale;
attack.isDead = progress >= 1;
if (attack.isCharged)
{
attack.angle = vec2_angle(attack.tip - attack.origin);
attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE);
attack.hitsLeft = attack.time > 0 ? 0 : attack.hitsLeft;
break;
}
auto isBlocked = [&](Vec2 point) {
return sim.world.is_blocking_at(point);
};
float unfurl = std::min(1.0f, progress / WHIP_UNFURL);
attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * WHIP_CONE * (2 * unfurl - 1);
Vec2 reach = ray_march(attack.origin, attack.angle, range, isBlocked);
attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE);
attack.length = std::max(0.0f, vec2_length(reach - attack.position));
attack.tip = attack.position + vec2_polar(attack.angle, attack.length * unfurl * (2 - unfurl));
attack.hitsLeft = unfurl < 1 ? attack.hitsLeft : 0;
break;
}
case ATTACK_BOOMERANG:
{
// Flies out easing to its peak, hovers there for its hold, then comes back to the hand. Touching a surface
// on the way out ends the throw there: a plain one turns straight back, a charged one hovers where it hit.
// Coming back, it slides along surfaces, and breaks off if it's wedged with nowhere to go.
attack.isReturning |= attack.time >= attack.duration + def.hold;
attack.radius = BOOMERANG_RADIUS * attack.scale;
if (attack.isReturning)
{
attack.isDead = boomerang_return(sim, fighter, attack) || attack.time > BOOMERANG_LIFETIME;
break;
}
if (attack.time >= attack.duration)
break;
// Walked in small steps so it can't skip through a thin platform.
float eased = 1 - (1 - progress) * (1 - progress);
Vec2 next = attack.origin + vec2_polar(attack.angle, range * eased);
Vec2 start = attack.position;
int steps = vec2_length(next - start) / BOOMERANG_STEP + 1;
bool isBlocked = false;
for (int step = 1; step <= steps && !isBlocked; step++)
{
Vec2 point = vec2_lerp(start, next, (float)step / steps);
isBlocked = sim.world.is_blocking_at(point);
if (!isBlocked)
attack.position = point;
}
if (!isBlocked)
break;
impact_play(sim, def.kind);
sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner);
attack.duration = attack.time;
attack.isReturning = !attack.isCharged;
break;
}
case ATTACK_SPIN:
{
// Keeps circling while left is held, finishing the current lap once let go.
int hand = fighter.hand_slot_get(attack.hand);
bool isHeld = fighter.is_down(CONTROL_ATTACK) && hand >= 0 && fighter.weapon_key(hand) == attack.key;
if (isHeld && attack.time >= attack.duration)
{
attack.time -= attack.duration;
sim.cues->sound_play(def.sound);
}
attack.angle += (attack.isLeft ? -2 : 2) * PI * TICK / attack.duration;
attack.position = fighter.center_get() + vec2_polar(attack.angle, range);
attack.radius = FLAIL_RADIUS * attack.scale;
attack.isDead = !isHeld && progress >= 1;
break;
}
case ATTACK_FLAME:
{
// Slows quickly out of the nozzle and drifts upward, stopping against walls.
attack.velocity = attack.velocity * FLAME_DRAG + Vec2{0, -FLAME_RISE * TICK};
Vec2 next = attack.position + attack.velocity * TICK;
if (sim.world.is_blocking_at(next))
attack.velocity = {};
else
attack.position = next;
attack.radius = FLAME_RADIUS * attack.scale;
attack.isDead = progress >= 1;
break;
}
case ATTACK_FIREBALL:
attack.radius = FIREBALL_RADIUS * attack.scale;
attack.position = attack.position + attack.velocity * TICK;
sim.cues->burst_spawn(EMBER_BURST, attack.position, attack.angle + PI, attack.owner);
if (sim.world.is_blocking_at(attack.position) || vec2_length(attack.position - attack.origin) > range)
attack_explode(sim, attack);
break;
case ATTACK_MINE:
{
// Glides out along the aim, easing to a stop, and floats there, arming (and blinking) a moment later. Walls
// stop it early.
if (progress >= 1)
break;
float eased = 1 - (1 - progress) * (1 - progress);
Vec2 next = attack.origin + vec2_polar(attack.angle, range * eased);
if (sim.world.is_blocking_at(next))
{
attack.duration = attack.time;
impact_play(sim, def.kind);
}
else
attack.position = next;
break;
}
case ATTACK_FLAIL:
attack.radius = FLAIL_RADIUS * attack.scale;
if (attack.isReturning)
attack.isDead = is_caught(fighter, attack);
else
flail_move(sim, attack);
break;
case ATTACK_HOOK:
{
attack.radius = HOOK_RADIUS * attack.scale;
if (attack.isAttached)
break;
if (attack.isCharged)
{
attack.isDead = attack.time >= HOOK_WHIFF_TIME;
break;
}
for (int step = 0; step < HOOK_SUBSTEPS && !attack.isAttached; step++)
{
attack.position = attack.position + attack.velocity * (TICK / HOOK_SUBSTEPS);
if (sim.world.is_blocking_at(attack.position))
hook_attach(sim, attack);
}
attack.isDead = !attack.isAttached && vec2_length(attack.position - fighter.hand_get()) > range;
break;
}
}
bool isShot = def.kind == ATTACK_PROJECTILE || def.kind == ATTACK_BULLET || def.kind == ATTACK_ARROW;
if (def.kind != ATTACK_POKE && def.kind != ATTACK_BEAM && def.kind != ATTACK_WHIP && !isShot)
attack.tip = attack.position;
}
// Keeps this tick's pose so drawing can blend from it. New attacks remember their first pose
// so they don't sweep in from where they were spawned.
void Weapons::attack_remember(Attack& attack)
{
attack.originPrevious = attack.origin;
attack.positionPrevious = attack.position;
attack.tipPrevious = attack.tip;
attack.anglePrevious = attack.angle;
attack.timePrevious = attack.time;
}
// Moves every attack alpha of the way through the last tick. Only for drawing.
void Weapons::blend(float alpha)
{
for (Attack& attack : attacks)
{
attack.origin = vec2_lerp(attack.originPrevious, attack.origin, alpha);
attack.position = vec2_lerp(attack.positionPrevious, attack.position, alpha);
attack.tip = vec2_lerp(attack.tipPrevious, attack.tip, alpha);
attack.angle = angle_lerp(attack.anglePrevious, attack.angle, alpha);
attack.time = math_lerp(attack.timePrevious, attack.time, alpha);
}
}
void Weapons::attack_hit(Sim& sim, Attack& attack, int target, Vec2 point)
{
const AttackDef& def = *attack.def();
if (def.kind == ATTACK_ROCKET || def.kind == ATTACK_FIREBALL)
{
attack_explode(sim, attack);
return;
}
impact_play(sim, def.kind);
// Explosions deal full damage at their centre, down to a fraction at the edge, by the target's nearest point.
float taper = 1;
if (def.kind == ATTACK_EXPLOSION)
{
Rect box = target_box(sim, target);
Vec2 nearest = {std::clamp(attack.position.x, box.x, box.x + box.w),
std::clamp(attack.position.y, box.y, box.y + box.h)};
float edge = std::min(1.0f, vec2_length(nearest - attack.position) / attack.stats.range);
taper = 1 - edge * (1 - SPLASH_EDGE);
}
int damage = std::max(1.0f, std::round(attack.stats.damage * multiplier_get(attack, point) * taper));
bool isHeavy = damage >= HEAVY_DAMAGE;
SoundId sound = hit_sound_get(attack.weapon >= 0 ? WEAPON_DEFS[attack.weapon].hit : HIT_BLUNT, isHeavy);
attack.hitsLeft--;
attack.hitCooldown = HIT_INTERVAL;
bool isShot = def.kind == ATTACK_PROJECTILE || def.kind == ATTACK_BULLET || def.kind == ATTACK_ARROW ||
def.kind == ATTACK_WISP;
attack.isDead |= isShot && attack.hitsLeft <= 0;
if (def.kind == ATTACK_HOOK)
hook_attach(sim, attack);
// Uppercuts knock the way they rise.
Rect box = target_box(sim, target);
Vec2 source = def.kind == ATTACK_EXPLOSION ? attack.position : sim.fighters[attack.owner].center_get();
if (def.kind == ATTACK_UPPERCUT)
source = Vec2{box.x + box.w / 2, box.y + box.h / 2} - vec2_polar(attack.angle, 1);
damage_deal(sim,
{
.point = point,
.source = source,
.hitstop = damage >= HITSTOP_MIN_DAMAGE ? std::min(HITSTOP_MAX, damage * HITSTOP_PER_DAMAGE) : 0,
.knock = attack.isGentle ? 0 : knock_get(def.kind),
.amount = damage,
.owner = attack.owner,
.target = target,
.sound = sound,
});
}
// Hurts a target with all the feedback: sparks, a number, shake that grows with the damage and a sound. The attacker
// freezes in hitstop, its Boxing Glove adds to the knock, and its Embiggener to the damage (a third of its growth).
// Dummies flash and wobble, blocks crack and break, and fighters take the damage themselves. In a race, fighters only
// get knocked about, so there's no number.
void Weapons::damage_deal(Sim& sim, Damage damage)
{
Fighter& attacker = sim.fighters[damage.owner];
Rect box = target_box(sim, damage.target);
float embiggen = attacker.trinket_get(TRINKET_EMBIGGENER, 1);
damage.amount = std::max(1, (int)std::round(damage.amount * (1 + (embiggen - 1) * EMBIGGEN_DAMAGE)));
float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1;
attacker.hitstop = std::max(attacker.hitstop, damage.hitstop);
damage.knock *= attacker.trinket_get(TRINKET_BOXING, 1);
if (damage.target < MAX_FIGHTERS)
sim.fighters[damage.target].damage_take(sim, damage);
else
{
Dummy& dummy = sim.world.dummies[damage.target - MAX_FIGHTERS];
dummy.flash = 1;
dummy.wobbleSpeed += side * WOBBLE_IMPULSE * damage.amount;
dummy.health -= damage.amount;
if (dummy.tile >= 0 && dummy.health <= 0)
sim.world.block_break(sim, dummy, damage.owner);
}
Burst burst = HIT_BURST;
burst.count += HIT_PARTICLES_PER_DAMAGE * damage.amount;
sim.cues->burst_spawn(burst, damage.point, side > 0 ? 0 : PI, damage.owner);
sim.cues->shake_add(damage.amount * SHAKE_PER_DAMAGE);
sim.cues->sound_play(damage.sound);
if (damage.target < MAX_FIGHTERS && sim.match.is_racing())
return;
sim.cues->number_spawn(damage);
}
// Pebble Helmet: touching another fighter or a dummy (not a block) hurts it, by the level's damage times the usual
// bonus for the fighter's speed, at most once every CONTACT_INTERVAL.
void Weapons::contact_update(Sim& sim, Fighter& fighter)
{
float base = fighter.trinket_get(TRINKET_PEBBLE, 0);
fighter.contactCooldown -= TICK;
if (base <= 0 || !fighter.isAlive || fighter.hitstop > 0 || fighter.contactCooldown > 0)
return;
Rect box = fighter.body.box;
int targets = MAX_FIGHTERS + sim.world.dummies.size();
for (int target = 0; target < targets; target++)
{
bool isFighter = target < MAX_FIGHTERS;
if (isFighter && (target >= sim.fighterCount || target == fighter.index || !sim.fighters[target].isAlive ||
!is_hostile(sim, fighter.index, target)))
continue;
if (!isFighter && sim.world.dummies[target - MAX_FIGHTERS].tile >= 0)
continue;
Rect other = target_box(sim, target);
if (!is_rect_overlapping(box, other))
continue;
float speed = vec2_length(fighter.body.velocity);
float bonus = 1 + (SPEED_DAMAGE + fighter.trinket_get(TRINKET_SPEEDOMETER, 0)) * speed / SPEED_UNIT;
int damage = std::max(1.0f, std::round(base * bonus));
Vec2 point = {(std::max(box.x, other.x) + std::min(box.x + box.w, other.x + other.w)) / 2,
(std::max(box.y, other.y) + std::min(box.y + box.h, other.y + other.h)) / 2};
damage_deal(
sim, {
.point = point,
.source = fighter.center_get(),
.hitstop = damage >= HITSTOP_MIN_DAMAGE ? std::min(HITSTOP_MAX, damage * HITSTOP_PER_DAMAGE) : 0,
.knock = CONTACT_KNOCK,
.amount = damage,
.owner = fighter.index,
.target = target,
.sound = hit_sound_get(HIT_BLUNT, damage >= HEAVY_DAMAGE),
});
fighter.contactCooldown = CONTACT_INTERVAL;
return;
}
}
// Every fighter's hovering wisps share one orbit around it: the oldest fill the inner ring, as many as fit round it
// WISP_SPACING apart, and the rest the rings beyond, so a crowd of wisps makes concentric rings. Each ring spreads its
// wisps evenly and spins the other way to the one inside it.
void Weapons::orbits_update(Sim& sim)
{
orbitClock = std::fmod(orbitClock + TICK, 2 * PI / WISP_SPIN);
std::vector<Attack*> hovering;
for (int i = 0; i < sim.fighterCount; i++)
{
float scale = sim.fighters[i].scale;
hovering.clear();
for (Attack& attack : attacks)
if (attack.owner == i && attack.def()->kind == ATTACK_WISP && !attack.isDead &&
(attack.isGathering || attack.time < attack.duration))
hovering.push_back(&attack);
int placed = 0;
for (int ring = 0; placed < (int)hovering.size(); ring++)
{
float radius = (WISP_RING + ring * WISP_RING_GAP) * scale;
int count =
std::min(std::max(1, (int)(2 * PI * radius / (WISP_SPACING * scale))), (int)hovering.size() - placed);
float spin = (ring % 2 == 0 ? 1 : -1) * WISP_SPIN * orbitClock;
for (int k = 0; k < count; k++)
{
hovering[placed + k]->orbitRadius = radius;
hovering[placed + k]->orbitAngle = -PI / 2 + spin + 2 * PI * k / count;
}
placed += count;
}
}
}
// Letting go of attack sends every wisp gathered in the fighter's orbit at the cursor, oldest first, one after
// another, all of them within WISP_RELEASE_SPAN however many there are.
void Weapons::wisps_release(Fighter& fighter)
{
int count = 0;
for (Attack& attack : attacks)
count += attack.owner == fighter.index && attack.isGathering && !attack.isDead;
float step = std::min(WISP_LAUNCH_STAGGER, WISP_RELEASE_SPAN / std::max(1, count));
int launched = 0;
for (Attack& attack : attacks)
{
if (attack.owner != fighter.index || !attack.isGathering || attack.isDead)
continue;
attack.isGathering = false;
attack.duration = attack.time + launched++ * step;
}
}
// Grimoire wisps: glide out to their place in the fighter's orbit (see orbits_update) and circle with it until their
// moment comes, then set off toward wherever the fighter's cursor is right then (plus their spread) and drift only
// very slightly after it (not once they're on top of it), fading out at the end of their range or against a wall (or
// ricocheting off it). They hit anything they touch from the start, orbiting included, and drift through platforms.
void Weapons::wisp_move(Sim& sim, Attack& attack)
{
Fighter& fighter = sim.fighters[attack.owner];
attack.radius = WISP_RADIUS * attack.scale;
if (attack.isGathering || attack.time < attack.duration)
{
Vec2 place = vec2_polar(attack.orbitAngle, attack.orbitRadius);
attack.orbit = attack.orbit + (place - attack.orbit) * std::min(1.0f, WISP_GLIDE * TICK);
attack.position = fighter.center_get() + attack.orbit;
attack.origin = attack.position;
return;
}
Vec2 toward = fighter.controls.cursor - attack.position;
if (attack.velocity.x == 0 && attack.velocity.y == 0)
{
attack.angle += vec2_angle(toward);
attack.velocity = vec2_polar(attack.angle, attack.stats.speed);
sim.cues->sound_play(SOUND_WISP);
}
else if (vec2_length(toward) > WISP_HOME_MIN)
{
float turn = std::remainder(vec2_angle(toward) - attack.angle, 2 * PI);
attack.angle += std::clamp(turn, -WISP_TURN * TICK, WISP_TURN * TICK);
attack.velocity = vec2_polar(attack.angle, vec2_length(attack.velocity));
}
Vec2 next = attack.position + attack.velocity * TICK;
if (sim.world.is_solid_at(next) && attack.bounces > 0)
{
ricochet(sim, attack, next);
return;
}
attack.length += vec2_length(next - attack.position);
attack.position = next;
bool isBlocked = sim.world.is_solid_at(attack.position);
if (!isBlocked && attack.length < attack.stats.range && attack.time < WISP_LIFETIME)
return;
attack.isDead = true;
if (isBlocked)
impact_play(sim, ATTACK_PROJECTILE);
sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner);
}
// Grimoire teleport: sparkles round the fighter while it gathers, then moves them to the spot marked when it was cast
// (backed off again if their box no longer fits), keeping all their momentum, with a blast there that hurts without
// knocking anyone about. Echoes only make the blast.
void Weapons::teleport_move(Sim& sim, Attack& attack)
{
Fighter& fighter = sim.fighters[attack.owner];
attack.radius = 0;
attack.hitsLeft = 0;
if (attack.time < attack.duration)
{
if (!attack.isCopy)
sim.cues->burst_spawn(WARP_BURST, fighter.center_get(), 0, attack.owner);
return;
}
attack.isDead = true;
Vec2 spot = attack.position;
if (!attack.isCopy && attack.hand == 0 && fighter.isAlive)
{
sim.cues->burst_spawn(PORTAL_BURST, fighter.center_get(), 0, attack.owner);
spot = teleport_spot(sim, fighter, attack.position, vec2_length(attack.position - fighter.center_get()));
fighter.teleport_to(spot);
sim.cues->shake_add(attack.def()->shake);
}
blast_spawn(sim, {
.position = spot,
.area = attack.stats.area * attack.scale,
.damage = attack.stats.damage,
.owner = attack.owner,
.sound = SOUND_TELEPORT,
.weapon = attack.weapon,
.isGentle = true,
.isSecondary = attack.isSecondary,
});
}
// Tesla Coil: a web of lightning between every weapon the fighter has out in the world (anything but explosions).
// Each piece arcs to its nearest few neighbours within reach, so everything nearby is connected without the
// links growing out of hand for rapid-fire weapons.
std::vector<Capsule> Weapons::chain_get(Sim& sim, Fighter& fighter)
{
std::vector<Capsule> links;
if (!fighter.is_trinket_equipped(TRINKET_TESLA))
return links;
float range = TESLA_RANGES[fighter.trinket_level_get(TRINKET_TESLA)];
std::vector<Vec2> pieces;
for (Attack& attack : attacks)
if (attack.owner == fighter.index && !attack.isDead && attack.def()->kind != ATTACK_EXPLOSION)
pieces.push_back(attack.position);
int count = pieces.size();
std::vector<bool> isLinked(count * count, false);
std::vector<std::pair<float, int>> near;
for (int i = 0; i < count; i++)
{
near.clear();
for (int j = 0; j < count; j++)
{
float distance = vec2_length(pieces[j] - pieces[i]);
if (j != i && distance <= range)
near.push_back({distance, j});
}
int reach = std::min((int)near.size(), TESLA_LINKS);
std::partial_sort(near.begin(), near.begin() + reach, near.end());
for (int k = 0; k < reach; k++)
{
int j = near[k].second;
if (isLinked[std::min(i, j) * count + std::max(i, j)])
continue;
isLinked[std::min(i, j) * count + std::max(i, j)] = true;
links.push_back({pieces[i], pieces[j], TESLA_RADIUS});
}
}
return links;
}
// Shocks every target a fighter's links touch, at most once per interval each.
void Weapons::tesla_update(Sim& sim)
{
for (int i = 0; i < sim.fighterCount; i++)
{
Fighter& fighter = sim.fighters[i];
float damage = fighter.trinket_get(TRINKET_TESLA, 0);
for (Capsule& link : chain_get(sim, fighter))
{
for (int target = 0; target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++)
{
Vec2 point;
if (!is_target(sim, target, i) || !is_capsule_in_rect(link, target_box(sim, target), point))
continue;
float& shock = target < MAX_FIGHTERS ? sim.fighters[target].shockCooldown
: sim.world.dummies[target - MAX_FIGHTERS].shockCooldown;
if (shock > 0)
continue;
shock = TESLA_INTERVAL;
damage_deal(sim, {.point = point,
.source = fighter.center_get(),
.knock = KNOCK_SLIGHT,
.amount = (int)damage,
.owner = i,
.target = target,
.sound = SOUND_ZAP});
}
}
}
}
// The paths the held weapon's attack would take if fired now (one per Splitter copy), out to the first wall it can't
// ricochet off (Ricochet bounces it off as many as the shot would) or the attack's reach: curving under gravity for
// arrows, straight for other shots, throws, beams, lashes, mines and hooks, and a teleport's line to where it lands.
// Anything that explodes rings the blast it would make where the path ends (its reach counts from the hand). Melee,
// flames and flails have none, as their paths aren't lines.
std::vector<Path> Weapons::paths_get(Sim& sim, Fighter& fighter)
{
std::vector<Path> paths;
int key = fighter.weapon_key(fighter.slot);
if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, 0)))
return paths;
const WeaponDef& weapon = WEAPON_DEFS[fighter.weaponLoadout[key]];
const LevelDef& level = fighter.level_get(key);
bool isCharged = fighter.charge >= 1;
const AttackDef& def = isCharged ? weapon.charged : weapon.normal;
AttackKind kind = def.kind;
AttackStats stats = isCharged ? level.charged : level.normal;
if (weapon.isDrawn)
stats = stats_blend(level, fighter.charge);
if (kind == ATTACK_TELEPORT)
{
Vec2 spot = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range);
Vec2 from = fighter.center_get();
Path path = {.area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1)};
float length = vec2_length(spot - from);
for (float travelled = 0; travelled < length; travelled += TRAJECTORY_STEP)
path.steps.push_back(from + (spot - from) * (travelled / length));
path.steps.push_back(spot);
paths.push_back(path);
return paths;
}
bool isArrow = kind == ATTACK_ARROW;
bool isStraight = kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ROCKET ||
kind == ATTACK_FIREBALL || kind == ATTACK_BEAM || kind == ATTACK_WHIP || kind == ATTACK_MINE ||
kind == ATTACK_BOOMERANG || (kind == ATTACK_HOOK && !isCharged);
if (!isArrow && !isStraight)
return paths;
// Each Splitter copy gets its own path, fanned like the copies. Straight paths step along the aim one unit at a
// time. Arrows fly at their real speed so gravity bends them.
int splits = fighter.trinket_get(TRINKET_SPLITTER, 1);
float area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1) * (splits > 1 ? SPLIT_SCALE : 1);
float reach = std::min(stats.range, TRAJECTORY_REACH);
for (int split = 0; split < splits; split++)
{
float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0)) : 0;
Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1);
float speed = stats.speed * fighter.trinket_get(TRINKET_ACCELERATOR, 1) + push_get(fighter, aim);
Vec2 velocity = isArrow ? aim * speed : aim;
Vec2 point = fighter.hand_get() + aim * MUZZLE_DISTANCE;
int bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0;
Path path;
bool isEnded = stats.range <= TRAJECTORY_REACH;
for (float travelled = area > 0 ? MUZZLE_DISTANCE : 0; travelled < reach; travelled += TRAJECTORY_STEP)
{
float time = TRAJECTORY_STEP / std::max(1.0f, vec2_length(velocity));
Vec2 next = point + velocity * time;
if (sim.world.is_blocking_at(next))
{
isEnded |= bounces <= 0;
if (bounces-- <= 0)
break;
velocity = reflect_get(sim, point, next, velocity);
continue;
}
velocity.y += isArrow ? ARROW_GRAVITY * time : 0;
path.steps.push_back(point);
point = next;
}
path.steps.push_back(point);
path.area = isEnded ? area : 0;
paths.push_back(path);
}
return paths;
}
// Latches the hook where it is and sets the rope to its fighter's current distance. Charged hooks start pulling.
void Weapons::hook_attach(Sim& sim, Attack& attack)
{
Fighter& fighter = sim.fighters[attack.owner];
attack.isAttached = true;
attack.isPulling = attack.isCharged;
attack.hitsLeft = 0;
attack.length = std::min(vec2_length(fighter.center_get() - attack.position), attack.stats.range);
sim.cues->sound_play(SOUND_LATCH);
sim.cues->shake_add(attack.def()->shake);
sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner);
}
// Traces instant attacks for one piece: beams march to the first wall and reflect off any Ricochet bounces,
// whips lash from the hand to the first wall (pulling their wielder toward it, and charged ones blowing up at the
// tip), and charged hooks plunge into
// the first grabbable surface or target (dummy or enemy fighter).
void Weapons::piece_launch(Sim& sim, Attack& piece)
{
if (piece.def()->kind == ATTACK_WHIP)
{
auto isBlocked = [&](Vec2 point) {
return sim.world.is_blocking_at(point);
};
piece.position = piece.origin;
piece.tip = ray_march(piece.origin, piece.angle, piece.stats.range, isBlocked);
Vec2 lash = piece.tip - piece.origin;
if (!piece.isCopy && vec2_length(lash) < piece.stats.range - RAY_STEP)
sim.fighters[piece.owner].impulse_add(vec2_normalize(lash) * WHIP_PULL *
sim.fighters[piece.owner].trinket_get(TRINKET_ABSORBER, 1));
if (piece.isCharged)
blast_spawn(sim, {
.position = piece.tip,
.area = piece.stats.area * piece.scale / piece.stats.scale,
.damage = piece.stats.damage,
.owner = piece.owner,
.sound = SOUND_NONE, // the charged crack already carries it
.weapon = piece.weapon,
.isSecondary = piece.isSecondary,
});
}
if (piece.def()->kind == ATTACK_BEAM)
{
auto isBlocked = [&](Vec2 point) {
return sim.world.is_blocking_at(point);
};
piece.tip = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked);
sim.cues->burst_spawn(SPARK_BURST, piece.tip, piece.angle + PI, piece.owner);
Attack segment = piece;
for (int i = 0; i < piece.bounces; i++)
{
segment.travelled += vec2_length(segment.tip - segment.position);
Vec2 before = segment.tip - vec2_polar(segment.angle, RAY_STEP);
segment.angle = vec2_angle(reflect_get(sim, before, segment.tip, vec2_polar(segment.angle, 1)));
segment.position = before;
segment.tip = ray_march(segment.position, segment.angle, piece.stats.range, isBlocked);
segment.ricochets++;
attacks.push_back(segment);
sim.cues->burst_spawn(SPARK_BURST, segment.tip, segment.angle + PI, piece.owner);
}
}
if (piece.def()->kind == ATTACK_HOOK && piece.isCharged)
{
auto isBlocked = [&](Vec2 point) {
for (int target = 0; target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++)
if (is_target(sim, target, piece.owner) && is_point_in_rect(point, target_box(sim, target)))
return true;
return sim.world.is_blocking_at(point);
};
piece.position = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked);
if (sim.world.is_blocking_at(piece.position))
hook_attach(sim, piece);
}
}
// Bursts a rocket, mine or fireball into a pushing explosion, keeping the damage raised by any ricochets and cut by
// how far it flew. The
// blast grows with trinkets but not with the attack's own size, so a big fireball keeps its listed area.
void Weapons::attack_explode(Sim& sim, Attack& attack)
{
attack.isDead = true;
attack.hitsLeft = 0;
blast_spawn(sim, {
.position = attack.position,
.area = blast_area_get(attack),
.bonus = strength_get(attack, attack.position),
.damage = attack.stats.damage * multiplier_get(attack, attack.position),
.owner = attack.owner,
.sound = SOUND_EXPLOSION,
.weapon = attack.weapon,
.isPushing = true,
.isSecondary = attack.isSecondary,
});
}
// Queues an explosion that hits everything inside it next tick, weaker toward its edge. Pushing blasts fling every
// fighter away from the centre, harder the closer they are. A harmless blast (an airblast) only knocks everyone but its
// owner within its area, launching them if close enough, shown as a ring.
void Weapons::blast_spawn(Sim& sim, Blast blast)
{
float reach = blast.area * (blast.isHarmless ? 1 : KNOCKBACK_REACH);
for (int i = 0; i < sim.fighterCount; i++)
{
Fighter& fighter = sim.fighters[i];
Vec2 away = fighter.center_get() - blast.position;
float distance = vec2_length(away);
// Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback.
float absorb = i == blast.owner ? fighter.trinket_get(TRINKET_ABSORBER, 1) : fighter.knock_absorb_get();
bool isPushed = blast.isHarmless ? i != blast.owner : blast.isPushing;
if (!isPushed || !fighter.isAlive || distance >= reach)
continue;
Vec2 push =
vec2_normalize(distance > 0 ? away : Vec2{0, -1}) * KNOCKBACK_SPEED * (1 - distance / reach) * absorb;
if (blast.isHarmless)
fighter.knock_take(push, blast.owner);
else
fighter.impulse_add(push);
}
if (blast.isHarmless)
{
sim.cues->ring_spawn(blast.position, reach, blast.owner);
return;
}
Attack attack = {
.stats = {.damage = blast.damage, .range = blast.area},
.origin = blast.position,
.position = blast.position,
.tip = blast.position,
.bonus = blast.bonus,
.duration = EXPLOSION_DEF.duration,
.hitsLeft = 1,
.owner = blast.owner,
.weapon = blast.weapon,
.isExplosion = true,
.isGentle = blast.isGentle,
.isSecondary = blast.isSecondary,
};
attack_remember(attack);
pending.push_back(attack);
sim.cues->sound_play(blast.sound);
sim.cues->shake_add(EXPLOSION_DEF.shake);
sim.cues->burst_spawn(BLAST_BURST, blast.position, 0, blast.owner);
}
// The whip lashing or hook flying out from this hand, which leaves only the weapon's base in it.
Attack* Weapons::tether_get(Fighter& fighter, int key, int hand)
{
for (Attack& attack : attacks)
{
AttackKind kind = attack.def()->kind;
if ((kind == ATTACK_WHIP || kind == ATTACK_HOOK) && attack.owner == fighter.index && attack.key == key &&
attack.hand == hand && !attack.isCopy && !attack.isDead)
return &attack;
}
return nullptr;
}
// Adds the fighter's own attacks it can stand on to the world for its next move: charged boomerangs in flight as
// obstacles it bumps into, and stuck arrows (until they start fading) as ledges it can only land on from above (and
// drop through).
void Weapons::solids_add(Fighter& fighter, World& world)
{
for (Attack& attack : attacks)
{
if (attack.owner != fighter.index || attack.isDead)
continue;
AttackKind kind = attack.def()->kind;
if (kind == ATTACK_BOOMERANG && attack.isCharged)
{
float size = BOOMERANG_RADIUS * attack.scale;
world.obstacles.push_back({attack.position.x - size, attack.position.y - size, size * 2, size * 2});
}
// A stuck arrow's ledge is the box around its hit shape.
if (kind == ATTACK_ARROW && attack.isAttached && attack.time < attack.duration - ARROW_FADE)
{
Vec2 low = {std::min(attack.position.x, attack.tip.x), std::min(attack.position.y, attack.tip.y)};
Vec2 high = {std::max(attack.position.x, attack.tip.x), std::max(attack.position.y, attack.tip.y)};
float radius = attack.radius;
world.ledges.push_back(
{low.x - radius, low.y - radius, high.x - low.x + radius * 2, high.y - low.y + radius * 2});
}
}
}
// Whether the fighter has a flail spinning around it.
bool Weapons::is_spinning(Fighter& fighter)
{
for (Attack& attack : attacks)
if (attack.owner == fighter.index && attack.def()->kind == ATTACK_SPIN && !attack.isDead)
return true;
return false;
}
// The latched grapple hook the fighter is hanging from, if any.
Attack* Weapons::hook_get(Fighter& fighter)
{
for (Attack& attack : attacks)
if (attack.owner == fighter.index && attack.def()->kind == ATTACK_HOOK && attack.isAttached && !attack.isDead)
return &attack;
return nullptr;
}
// Removes every attack of one kind the fighter owns at once, like all grapple hooks so Splitter copies drop together.
void Weapons::attacks_release(Fighter& fighter, AttackKind kind)
{
for (Attack& attack : attacks)
attack.isDead |= attack.owner == fighter.index && attack.def()->kind == kind;
}
// A fresh press calls thrown weapons back: Charge recalls boomerangs once all are out, Attack or Charge
// reels in a flail ball. Reports whether it did.
bool Weapons::is_recalled(Sim& sim, Fighter& fighter)
{
int key = fighter.weapon_key(fighter.slot);
if (key < 0)
return false;
bool isLeft = fighter.is_pressed(CONTROL_ATTACK);
bool isRight = fighter.is_pressed(CONTROL_CHARGE);
bool isBoomerang =
WEAPON_DEFS[fighter.weaponLoadout[key]].normal.kind == ATTACK_BOOMERANG && isRight && is_busy(fighter, key);
bool isFlail = false;
for (Attack& attack : attacks)
isFlail |= attack.def()->kind == ATTACK_FLAIL && attack.owner == fighter.index && attack.key == key &&
!attack.isReturning;
isFlail &= isLeft || isRight;
if (!isBoomerang && !isFlail)
return false;
for (Attack& attack : attacks)
attack.isReturning |= attack.owner == fighter.index && attack.key == key;
return true;
}
// Swings the thrown ball under gravity on its chain, then lets the world's tile collision move it,
// adding a small bounce and ground friction. Hard landings thud.
void Weapons::flail_move(Sim& sim, Attack& attack)
{
Fighter& fighter = sim.fighters[attack.owner];
attack.velocity.y += GRAVITY * TICK;
Vec2 hand = fighter.hand_get();
Vec2 offset = attack.position + attack.velocity * TICK - hand;
if (vec2_length(offset) > attack.stats.range)
attack.velocity = (hand + vec2_normalize(offset) * attack.stats.range - attack.position) * (1 / TICK);
float size = FLAIL_SIZE * attack.scale;
Body body = {
.box = {attack.position.x - size / 2, attack.position.y - size / 2, size, size},
.velocity = attack.velocity,
};
Vec2 before = body.velocity;
sim.world.body_move(body);
bool isThudX = body.velocity.x == 0 && std::abs(before.x) > FLAIL_THUD_SPEED;
bool isThudY = body.velocity.y == 0 && std::abs(before.y) > FLAIL_THUD_SPEED;
if (isThudX || isThudY)
impact_play(sim, ATTACK_FLAIL);
float bounce =
std::min(FLAIL_BOUNCE_MAX, FLAIL_BOUNCE + FLAIL_BOUNCE_PER_RICOCHET * fighter.trinket_get(TRINKET_RICOCHET, 0));
if (body.velocity.x == 0 && std::abs(before.x) > FLAIL_BOUNCE_MIN)
body.velocity.x = -before.x * bounce;
if (body.velocity.y == 0 && std::abs(before.y) > FLAIL_BOUNCE_MIN)
body.velocity.y = -before.y * bounce;
if (body.isGrounded)
body.velocity.x =
std::clamp(0.0f, body.velocity.x - FLAIL_FRICTION * TICK, body.velocity.x + FLAIL_FRICTION * TICK);
attack.position = {body.box.x + size / 2, body.box.y + size / 2};
attack.velocity = body.velocity;
}
// Anything that leaves the hand (swings, pokes, throws, hooks) takes the weapon out of it.
// A weapon (by its key, see Fighter::weapon_key) is busy once as many are out as its level allows. Splitter and
// Doubler copies don't count.
bool Weapons::is_busy(Fighter& fighter, int key)
{
int out = 0;
for (Attack& attack : attacks)
{
AttackKind kind = attack.def()->kind;
bool isHeld = kind == ATTACK_SWING || kind == ATTACK_POKE || kind == ATTACK_BOOMERANG || kind == ATTACK_HOOK ||
kind == ATTACK_SPIN || kind == ATTACK_FLAIL;
out += attack.owner == fighter.index && attack.key == key && !attack.isDead && !attack.isCopy && isHeld;
}
return out >= fighter.level_get(key).count;
}