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>
1961 lines
86 KiB
C++
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;
|
|
}
|