Files
smack-a-brother/src/fighter.cpp
T
shweet b96defa1e6 Profile stats, first-run hints, tiered balance, a wave-driven whip, and more options
- Profile Stats: damage dealt, kills and wins (single player and multiplayer) and the most and least played weapons
  and trinkets, counted from running totals in the simulation so online rewinds correct them. Protocol 6.
- First-run control hints in the sandbox (and Chat in lobbies), each gone for good once used.
- The ready countdown and a timed round's last five seconds beep (new countdown sound), and those seconds show big.
  When time runs out, everyone short of the most health dies.
- Weapon balance tuned into tiers: Whip and Grenade Launcher on top, then melee, ranged, and utility (Vacuum,
  Grapple, Uzi) at the bottom.
- The whip's lash is a chain of links a wave rolls down: windup, loop, crack, then a floppy recoil.
- Muzzles come from Muzzle nulls in weapons.anm2 (read in at build time), for shots and the flash, which now follows
  the weapon.
- Settings: Quit, Scale (1x-4x or Free), FPS meter and limit beside Vsync, Mute on Unfocus, Damage Numbers, Rumble,
  Stick Deadzone, and an Online section (Host, Chat, Ping) shared with the Multiplayer page.
- The menu header lays out tabs and buttons by their names. About is sandbox-only. The score no longer shifts with
  the menu's tab.
- Smaller meters, 90 tips (all ending in !), and a shorter lobby welcome.
2026-11-10 18:32:31 -05:00

939 lines
37 KiB
C++

#include "fighter.h"
#include "sim.h"
#include <algorithm>
constexpr float AIR_ACCELERATION = 400;
constexpr float BOUNCE_SHAKE = 0.00025f;
constexpr float SLAM_DAMAGE = 0.06f;
constexpr float KNOCK_BASE = 60;
constexpr float KNOCK_LIFT = 0.35f;
constexpr float KNOCK_MAX = 4500;
constexpr float KNOCK_PER_DAMAGE = 30;
constexpr float LAUNCH_START_SPEED = 300;
constexpr float AFTERIMAGE_SPEED = 440;
constexpr float AIR_FRICTION = 600;
constexpr float CLING_SPEED = 60;
constexpr float COYOTE_TIME = 0.1f;
constexpr float DASH_COOLDOWN = 0.8f;
constexpr float DASH_TIME = 0.12f;
constexpr float DEATH_SHAKE = 0.75f;
constexpr float FACING_DEADZONE = 4;
constexpr float FAST_FALL_GRAVITY = 2;
constexpr float FLASH_DECAY = 8;
constexpr float FAST_FALL_SPEED = 700;
constexpr float FALL_EASE = 2000;
constexpr float GROUND_ACCELERATION = 500;
constexpr float GROUND_FRICTION = 3000;
constexpr float JUMP_BUFFER = 0.1f;
constexpr float JUMP_RELEASE_SPEED = 160;
constexpr float KICK_COOLDOWN = 1.2f;
constexpr float LAUNCH_DRAG = 160;
constexpr float LAND_SPEED = 150;
// The most charge a normal attack can carry, just short of a full charge's charged attack.
constexpr float CHARGE_NORMAL_MAX = 0.999f;
constexpr float MAX_FALL = 520;
constexpr float ROPE_MIN = 24;
constexpr float SLIDE_DUST_INTERVAL = 0.08f;
constexpr float SWING_ACCELERATION = 500;
constexpr float SWING_SPEED_MAX = 420;
constexpr float POUND_SCALE_MAX = 3;
constexpr float POUND_SPEED = 700;
constexpr Vec2 HAND_OFFSET = {4, 2};
constexpr Vec2 OFFHAND_OFFSET = {-3, 4};
constexpr Vec2 OFFHAND_FAN = {3, 2};
constexpr float WALL_IMPACT_SPEED = 200;
constexpr float WALL_LIFT = 0.25f;
constexpr float WALL_MEMORY = 0.6f;
constexpr float WALL_REFLECT = 1.2f;
constexpr float WALL_POINT_HEIGHT = 0.4f;
constexpr Vec2 KICK_VELOCITY = {380, -260};
constexpr float KICK_STACK_BOOST = 0.25f;
constexpr float HITSTOP_RUN_MAX = 1;
constexpr Vec2 WALL_JUMP_VELOCITY = {180, -JUMP_SPEED};
constexpr float CROUCH_HEIGHT = 0.75f;
constexpr float CROUCH_DAMP = 0.5f;
constexpr float KICK_POSE_TIME = 0.2f;
constexpr float SPIN_GRAVITY = 0.5f;
constexpr float CRAWL_SPEED = 0.35f;
constexpr float DOWNHILL_BOOST = 0.6f;
constexpr float MOMENTUM_DRAG = 400;
constexpr float SLIDE_FRICTION = 150;
constexpr float SLIDE_MAX = 700;
constexpr float SLIDE_MIN = 60;
constexpr float TUMBLE_RATE = 0.012f;
// Footsteps per animation frame walked or crawled: one per four-frame walk cycle, one per half crawl cycle.
constexpr float WALK_STEP_FRAMES = 4;
constexpr float CRAWL_STEP_FRAMES = 2;
constexpr Burst LAND_DUST = {
.angle = -PI / 2, .gravity = 150, .life = 0.35f, .speed = 90, .spread = PI * 0.45f, .count = 10};
constexpr Burst DEATH_BURST = {.gravity = 300, .life = 0.8f, .size = 4, .speed = 260, .count = 40};
constexpr Burst BOUNCE_DUST = {.gravity = 200, .life = 0.4f, .speed = 160, .count = 10};
constexpr Burst KICK_DUST = {.gravity = 200, .life = 0.35f, .speed = 120, .spread = PI / 3, .count = 8};
constexpr Burst READY_BURST = {.life = 0.4f, .size = 2, .speed = 120, .count = 12};
constexpr Burst SLIDE_DUST = {.gravity = -40, .life = 0.3f, .size = 2, .speed = 30, .spread = PI / 3, .count = 1};
constexpr Burst STEP_DUST = {
.angle = -PI / 2, .gravity = 150, .life = 0.25f, .size = 2, .speed = 40, .spread = PI / 2, .count = 2};
// Makes this fighter number, with empty loadouts, standing at its spawn.
void Fighter::init(Sim& sim, int number)
{
index = number;
std::fill(std::begin(weaponLoadout), std::end(weaponLoadout), -1);
std::fill(std::begin(trinketLoadout), std::end(trinketLoadout), -1);
state_reset(sim, 1);
}
// Uses the first weapons and trinkets slots of each loadout, emptying the rest, and holds a slot in use.
void Fighter::slots_set(int weapons, int trinkets)
{
weaponSlots = weapons;
trinketSlots = trinkets;
std::fill(weaponLoadout + weapons, std::end(weaponLoadout), -1);
std::fill(trinketLoadout + trinkets, std::end(trinketLoadout), -1);
slot = std::min(slot, weapons - 1);
}
// Back at its spawn and still: no velocity, nothing charging, no echoes, not launched or stuck in hitstop.
void Fighter::position_reset(Sim& sim)
{
position_set(sim, sim.world.spawns[index]);
}
// Puts the fighter still with its feet at a point, nothing charging.
void Fighter::position_set(Sim& sim, Vec2 spawn)
{
charge_cancel(sim);
body = {.box = {spawn.x - BOX_WIDTH / 2, spawn.y - BOX_HEIGHT, BOX_WIDTH, BOX_HEIGHT}};
cornerPrevious = {body.box.x, body.box.y};
safe = spawn;
echoes.clear();
hitstop = 0;
hitstopRun = 0;
isLaunched = false;
}
// Back at its spawn, alive, at full health and still, holding its first slot, with nothing charging or cooling
// down. Loadouts and levels stay as they are.
void Fighter::state_reset(Sim& sim, float maxHealth)
{
position_reset(sim);
std::fill(std::begin(cooldowns), std::end(cooldowns), 0.0f);
flash = 0;
health = maxHealth;
std::fill(std::begin(slotSwitches), std::end(slotSwitches), 0);
slot = 0;
isAlive = true;
}
// Loses health with a flash and hitstop, and gets knocked away from where the hit came from (and a little upward),
// up to a speed that grows with the damage (see knock_take). Shock Absorber softens it. Running out of health kills it
// in a burst, and its attacks vanish with it.
void Fighter::damage_take(Sim& sim, Damage damage)
{
// Races and the sandbox never cost health.
bool isRacing = sim.match.is_racing();
health -= isRacing || sim.match.state == MATCH_PRACTICE ? 0 : damage.amount;
flash = 1;
hitstop_add(damage.hitstop);
Vec2 away = center_get() - damage.source;
Vec2 direction = vec2_normalize(vec2_normalize(vec2_length(away) > 1 ? away : Vec2{0, -1}) + Vec2{0, -KNOCK_LIFT});
float knock = std::min(KNOCK_MAX, KNOCK_BASE + damage.amount * KNOCK_PER_DAMAGE) * damage.knock *
(isRacing ? RACE_KNOCK : 1) * stat_get(STAT_KNOCKBACK_TAKEN);
knock_take(direction * knock, damage.owner);
if (health > 0)
return;
die(sim);
if (damage.owner != index)
sim.fighters[damage.owner].kills++;
}
// Out of the round in a burst, its health gone and its attacks vanishing with it.
void Fighter::die(Sim& sim)
{
health = 0;
isAlive = false;
charge_cancel(sim);
for (Attack& attack : sim.weapons.attacks)
attack.isDead |= attack.owner == index;
sim.cues->sound_play(SOUND_DEATH);
sim.cues->burst_spawn(DEATH_BURST, center_get(), 0, index);
sim.cues->shake_add(DEATH_SHAKE);
}
void Fighter::update(Sim& sim)
{
// Out of the round, it still keeps track of its buttons, so a held one (like Ready on the results) counts once.
// Its hitstop runs out too, or the attacks it left out would stay frozen with it.
if (!isAlive)
{
hitstop = std::max(0.0f, hitstop - TICK);
buttonsPrevious = controls.buttons;
return;
}
cornerPrevious = {body.box.x, body.box.y};
flash = std::max(0.0f, flash - FLASH_DECAY * TICK);
shockCooldown -= TICK;
donkWindow -= TICK;
if (hitstop > 0)
{
hitstop -= TICK;
hitstopRun += TICK;
return;
}
hitstopRun = 0;
for (float& cooldown : cooldowns)
cooldown = std::max(0.0f, cooldown - TICK);
kickTime += TICK;
Attack* hook = sim.weapons.hook_get(*this);
slot_update(sim);
// A slide keeps rolling through the air for as long as S is held, like off a kicker or jumping mid-slide.
bool wasCrouching = isCrouching;
bool isTucked = is_down(CONTROL_DOWN) && !isLaunched;
isCrouching = body.isGrounded && isTucked;
if (isCrouching != wasCrouching && body.isGrounded && !isSliding)
sim.cues->sound_play(isCrouching ? SOUND_DUCK : SOUND_UNDUCK);
bool isSlideKept = isSliding && (std::abs(body.velocity.x) > SLIDE_MIN || !body.isGrounded);
isSliding = isTucked && (body.isGrounded ? body.slope != 0 || isSlideKept : isSliding);
size_update();
isSpinning = sim.weapons.is_spinning(*this);
run_update(hook);
body.isDropping = is_down(CONTROL_DOWN);
bool isJumpHeld = is_down(CONTROL_JUMP) || (!hook && is_down(CONTROL_UP));
bool isJumpPressed = is_pressed(CONTROL_JUMP) || (!hook && is_pressed(CONTROL_UP));
jumpBuffer = isJumpPressed ? JUMP_BUFFER : jumpBuffer - TICK;
coyoteTimer = body.isGrounded ? COYOTE_TIME : coyoteTimer - TICK;
Vec2 feet = {center_get().x, body.box.y + body.box.h};
climb_update(sim);
if (jumpBuffer > 0 && coyoteTimer > 0 && !isLaunched)
{
body.velocity.y = -JUMP_SPEED;
jumpBuffer = 0;
coyoteTimer = 0;
isJumping = true;
sim.cues->sound_play(SOUND_JUMP);
sim.cues->burst_spawn(STEP_DUST, feet, 0, index);
}
dash_update(sim, isJumpPressed);
isJumping &= body.velocity.y < 0 && !hook;
if (isJumping && !isJumpHeld && body.velocity.y < -JUMP_RELEASE_SPEED)
body.velocity.y = -JUMP_RELEASE_SPEED;
if (hook)
rope_update(*hook);
bool wasGrounded = body.isGrounded;
float fallSpeed = body.velocity.y;
float runSpeed = body.velocity.x;
sim.weapons.solids_add(*this, sim.world);
sim.world.body_move(body);
sim.world.obstacles.clear();
sim.world.ledges.clear();
if (isLaunched)
bounce_resolve(sim, {runSpeed, fallSpeed});
safe_update(sim);
wallMemory -= TICK;
bool isWallHit = body.velocity.x == 0 && std::abs(runSpeed) > WALL_IMPACT_SPEED;
if (isWallHit && is_trinket_equipped(TRINKET_CLIMBING))
{
wallSpeed = std::abs(runSpeed);
wallMemory = WALL_MEMORY;
}
if (wallMemory <= 0)
wallSpeed = 0;
if (vec2_length(body.velocity) > AFTERIMAGE_SPEED || dashTimer > 0)
sim.cues->fighter_afterimage_spawn(*this);
feet = {center_get().x, body.box.y + body.box.h};
if (body.isGrounded && !wasGrounded && fallSpeed > LAND_SPEED)
{
sim.cues->sound_play(SOUND_LAND);
sim.cues->burst_spawn(LAND_DUST, feet, 0, index);
}
// Iron Weights slam: landing faster than a plain fall can go (on anything, platforms too), and the harder the
// landing, the harder the blast. Every copy adds to its damage and radius. Damage follows the landing speed, and
// the radius its square root.
if (body.isGrounded && !wasGrounded && fallSpeed > MAX_FALL && is_trinket_equipped(TRINKET_WEIGHTS))
{
float impact = std::min(fallSpeed / POUND_SPEED, POUND_SCALE_MAX);
sim.weapons.blast_spawn(sim, {.position = feet,
.area = stat_get(STAT_SLAM_AREA) * std::sqrt(impact),
.damage = stat_get(STAT_SLAM_DAMAGE) * impact,
.owner = index,
.sound = SOUND_POUND});
}
// A kick holds its pose briefly, and a launch tumbles faster the faster it flies.
kickPose -= TICK;
float spin = body.velocity.x < 0 ? -1 : 1;
tumble = isLaunched ? tumble + spin * vec2_length(body.velocity) * TUMBLE_RATE * TICK : 0;
// The ball rolls without slipping: on the ground it spins with its speed along the surface, and in the air it
// keeps the spin it left with.
float rollBefore = roll;
if (body.isGrounded)
rollSpin = body.velocity.x * std::sqrt(1 + body.slope * body.slope) / (ROLL_RADIUS * scale);
roll = isSliding ? roll + rollSpin * TICK : 0;
// Footsteps only while walking on purpose or crawling, and a roll bumps once every half turn. Steps go by ground
// covered, in time with the legs (whose animation steps a frame per stride), so they quicken with speed; the first
// lands as the walk starts.
bool isMoving = is_down(CONTROL_LEFT) || is_down(CONTROL_RIGHT);
bool isWalking =
body.isGrounded && !isCrouching && !isLaunched && isMoving && std::abs(body.velocity.x) > STEP_SPEED;
if (isWalking || is_crawling())
{
stepDistance -= std::abs(body.velocity.x) * TICK;
if (stepDistance <= 0)
{
stepDistance += isWalking ? WALK_STRIDE * WALK_STEP_FRAMES : CRAWL_STRIDE * CRAWL_STEP_FRAMES;
sim.cues->sound_play(isWalking ? SOUND_STEP : SOUND_CRAWL);
if (isWalking)
sim.cues->burst_spawn(STEP_DUST, feet, 0, index);
}
}
else
stepDistance = 0;
if (isSliding && body.isGrounded && std::floor(roll / PI) != std::floor(rollBefore / PI))
sim.cues->sound_play(SOUND_ROLL);
// Facing follows the cursor's side of the body, holding still when it's nearly straight above or below, so the
// hand (which sits on the facing side) can't flip back and forth under it.
float cursorX = controls.cursor.x - center_get().x;
if (std::abs(cursorX) > FACING_DEADZONE)
isFacingLeft = cursorX < 0;
aim = vec2_normalize(controls.cursor - hand_get());
charge_update(sim);
buttonsPrevious = controls.buttons;
}
// Horizontal control and gravity. Holding S in the air falls faster (Iron Weights speeding up both how fast it gets
// there and how fast it goes), and letting go eases back to a plain fall rather than stopping short. Holding W floats
// with the Feather, and a spinning flail's pull lightens gravity. Slopes change the top speed, and crouch-slides answer
// only to the slope. Crouching on flat ground waddles along slowly.
// Airborne on a rope, moving left and right pumps the swing instead.
// Above run speed in the air, launch momentum bleeds off slowly unless steered against. Launched by a hit, there's no
// control at all, just gravity and the knock dying down.
void Fighter::run_update(Attack* hook)
{
float direction = (float)is_down(CONTROL_RIGHT) - (float)is_down(CONTROL_LEFT);
float speed = std::abs(body.velocity.x);
float sign = body.velocity.x < 0 ? -1 : 1;
float boots = stat_get(STAT_RUN_SPEED);
float runSpeed = RUN_SPEED * boots * (isCrouching ? CRAWL_SPEED : 1);
if (dashTimer > 0)
return;
if (isLaunched)
{
body.velocity =
body.velocity * (1 - KNOCK_DECAY * TICK) + Vec2{0, GRAVITY * (isSpinning ? SPIN_GRAVITY : 1) * TICK};
return;
}
bool isAirborne = !body.isGrounded && !hook;
bool isFastFalling = isAirborne && is_down(CONTROL_DOWN) && !isSliding;
bool isFloating = isAirborne && is_down(CONTROL_UP);
float weights = isFastFalling ? stat_get(STAT_FAST_FALL) : 1;
float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY * weights : 1);
gravity *= isFloating ? stat_get(STAT_FLOAT_GRAVITY) : 1;
gravity *= isSpinning ? SPIN_GRAVITY : 1;
float maxFall = isFastFalling ? FAST_FALL_SPEED * weights : MAX_FALL;
if (body.velocity.y > maxFall)
body.velocity.y = std::max(maxFall, body.velocity.y - FALL_EASE * TICK);
else
body.velocity.y = std::min(body.velocity.y + gravity * TICK, maxFall);
// Sliding: the slope pulls it downhill and little else slows it.
if (isSliding)
{
float slope = body.slope;
body.velocity.x += GRAVITY * slope / (1 + slope * slope) * TICK;
float slowed = std::max(0.0f, std::abs(body.velocity.x) - SLIDE_FRICTION * TICK);
body.velocity.x = std::clamp(std::copysign(slowed, body.velocity.x), -SLIDE_MAX, SLIDE_MAX);
return;
}
if (hook && !body.isGrounded)
{
if (direction * body.velocity.x <= 0 || speed < SWING_SPEED_MAX)
body.velocity.x += direction * SWING_ACCELERATION * TICK;
return;
}
// On a slope, the top speed drops climbing and rises descending, with the angle.
float slope = body.isGrounded ? body.slope : 0;
if (slope != 0 && direction != 0)
{
float cosine = 1 / std::sqrt(1 + slope * slope);
bool isUphill = direction * slope < 0;
runSpeed *= isUphill ? cosine : 1 + DOWNHILL_BOOST * std::abs(slope) * cosine;
}
// Faster than running, momentum bleeds off slowly unless steered against (or let go of on the ground).
if (speed > runSpeed && direction * body.velocity.x >= 0 && (direction != 0 || !body.isGrounded))
{
float drag = body.isGrounded ? MOMENTUM_DRAG : LAUNCH_DRAG;
body.velocity.x = sign * std::max(runSpeed, speed - drag * TICK);
return;
}
float acceleration = (body.isGrounded ? GROUND_ACCELERATION : AIR_ACCELERATION) * boots;
float friction = body.isGrounded ? GROUND_FRICTION : AIR_FRICTION;
bool isTurning = direction * body.velocity.x < 0;
float rate = (direction == 0 ? friction : acceleration + (isTurning ? friction : 0)) * TICK;
body.velocity.x = std::clamp(direction * runSpeed, body.velocity.x - rate, body.velocity.x + rate);
}
// Up and Down reel the rope, or a charged hook yanks it in until short (slower under Shock Absorber). When the next
// step would leave the rope's reach, the step is pulled back onto the circle, so outward motion becomes swing and
// reeling in drags the player along.
void Fighter::rope_update(Attack& hook)
{
float reel = (float)is_down(CONTROL_DOWN) - (float)is_down(CONTROL_UP);
reel *= WEAPON_DEFS[hook.weapon].levels[hook.key >= 0 ? slot_level_get(hook.key) : 0].reel;
if (hook.isPulling)
reel = -hook.stats.speed * stat_get(STAT_MOVEMENT);
hook.length = std::clamp(hook.length + reel * TICK, ROPE_MIN, hook.stats.range);
hook.isPulling &= hook.length > ROPE_MIN;
Vec2 center = center_get();
Vec2 offset = center + body.velocity * TICK - hook.position;
if (vec2_length(offset) <= hook.length)
return;
Vec2 next = hook.position + vec2_normalize(offset) * hook.length;
body.velocity = (next - center) * (1 / TICK);
}
// The controls' slot picks a weapon slot, wrapping around, and the level buttons level the weapon copy in it. The
// weapon switched away from becomes the off-hand weapon for Dual Wield (a copy of the same weapon counts when weapons
// stack). A thrown flail ball or grapple out stays out.
void Fighter::slot_update(Sim& sim)
{
int previous = slot;
if (controls.slot != -1)
slot = (controls.slot % weaponSlots + weaponSlots) % weaponSlots;
int held = weapon_get();
if (slot != previous)
sim.cues->sound_play(SOUND_SELECT);
if (slot != previous && weaponLoadout[previous] >= 0 && weapon_key(previous) != weapon_key(slot))
slotSwitches[previous] = ++switches;
int step = (int)is_pressed(CONTROL_LEVEL_UP) - (int)is_pressed(CONTROL_LEVEL_DOWN);
if (step != 0 && held >= 0 && sim.match.state == MATCH_PRACTICE)
level_step(sim, weaponSlotLevels[slot], step);
}
// Shrinker, Embiggener and crouching: resizes the box around the feet to the fighter's current size.
void Fighter::size_update()
{
scale = stat_get(STAT_SIZE);
float height = BOX_HEIGHT * scale * (isCrouching || isSliding ? CROUCH_HEIGHT : 1);
Vec2 feet = {center_get().x, body.box.y + body.box.h};
body.box = {feet.x - BOX_WIDTH * scale / 2, feet.y - height, BOX_WIDTH * scale, height};
}
// Wings: a jump press in the air that nothing else used (no ground, coyote or wall jump) dashes toward the
// cursor, adding a push on top of the current velocity and holding it briefly without gravity. Stacked copies don't
// add speed (the best copy's counts) but each holds a dash: the cooldown builds up a dash's worth per dash, and dashing
// works while there's a dash's worth left to build up before all of them are spent.
void Fighter::dash_update(Sim& sim, bool isJumpPressed)
{
float speed = stat_get(STAT_DASH_SPEED);
dashTimer -= TICK;
dashCooldown = std::max(0.0f, dashCooldown - TICK);
bool isUnused = isJumpPressed && jumpBuffer > 0 && !body.isGrounded && coyoteTimer <= 0;
int dashes = trinket_count(TRINKET_WINGS);
if (speed <= 0 || !isUnused || dashCooldown > DASH_COOLDOWN * (dashes - 1) || isLaunched)
return;
jumpBuffer = 0;
burst_add(aim * speed);
dashCooldown += DASH_COOLDOWN;
sim.cues->sound_play(SOUND_DASH);
sim.cues->burst_spawn(STEP_DUST, center_get(), 0, index);
}
// Climb Claw, by level. 1: pushing into a wall slows the slide down it, trailing dust. 2: pressing away kicks
// off, on a cooldown so it can't climb forever. 3: Jump wall jumps freely.
void Fighter::climb_update(Sim& sim)
{
int level = stat_get(STAT_CLIMB);
int wall = wall_get(sim);
kickTimer -= TICK;
slideWall = 0;
if (level == 0 || wall == 0 || body.isGrounded || isLaunched)
return;
float direction = (float)is_down(CONTROL_RIGHT) - (float)is_down(CONTROL_LEFT);
slideTimer -= TICK;
if (direction == wall && body.velocity.y > 0)
{
body.velocity.y = std::min(body.velocity.y, CLING_SPEED);
slideWall = wall;
if (slideTimer <= 0)
sim.cues->burst_spawn(SLIDE_DUST, wall_point_get(wall), wall > 0 ? PI : 0, index);
slideTimer = slideTimer <= 0 ? SLIDE_DUST_INTERVAL : slideTimer;
}
Control away = wall > 0 ? CONTROL_LEFT : CONTROL_RIGHT;
// Each copy past the first kicks off a quarter harder.
if (level >= 2 && kickTimer <= 0 && is_pressed(away))
{
wall_push(sim, wall, KICK_VELOCITY * (1 + KICK_STACK_BOOST * (trinket_count(TRINKET_CLIMBING) - 1)));
kickTimer = KICK_COOLDOWN;
isJumping = false;
}
if (level >= 3 && jumpBuffer > 0)
{
wall_push(sim, wall, WALL_JUMP_VELOCITY);
jumpBuffer = 0;
isJumping = true;
}
}
// Pushes off the wall with at least the base velocity. A hard hit into the wall in the last moment is reflected
// back out, a little stronger, with some of it turned into extra lift.
void Fighter::wall_push(Sim& sim, int wall, Vec2 base)
{
float outward = std::max(base.x, wallSpeed * WALL_REFLECT);
body.velocity = {-wall * outward, base.y - wallSpeed * WALL_LIFT};
wallSpeed = 0;
kickPose = KICK_POSE_TIME;
sim.cues->sound_play(SOUND_JUMP);
sim.cues->burst_spawn(KICK_DUST, wall_point_get(wall), wall > 0 ? PI : 0, index);
}
// Launched fighters bounce off whatever stopped them, keeping most of their speed, with a thud, dust and shake that
// grow with the impact. Hard impacts slam them for damage, credited to their launcher. The launch ends once they've
// slowed down.
void Fighter::bounce_resolve(Sim& sim, Vec2 before)
{
float impact = 0;
Vec2 normal = body.normal;
float into = vec2_dot(before, normal);
if (into < -BOUNCE_MIN)
{
body.velocity = before - normal * (into * (1 + BOUNCE_KEEP));
impact = -into;
}
else
{
if (body.velocity.x == 0 && std::abs(before.x) > BOUNCE_MIN)
{
body.velocity.x = -before.x * BOUNCE_KEEP;
impact = std::abs(before.x);
}
if (body.velocity.y == 0 && std::abs(before.y) > BOUNCE_MIN)
{
body.velocity.y = -before.y * BOUNCE_KEEP;
impact = std::max(impact, std::abs(before.y));
}
}
if (impact > 0)
{
sim.cues->sound_play(SOUND_BOUNCE);
sim.cues->burst_spawn(BOUNCE_DUST, center_get(), 0, index);
sim.cues->shake_add(impact * BOUNCE_SHAKE);
}
int slam = (impact - SLAM_SPEED) * SLAM_DAMAGE;
if (slam > 0)
sim.weapons.damage_deal(sim, {.point = center_get(),
.source = center_get(),
.knock = 0,
.amount = slam,
.owner = launcher,
.target = index,
.sound = SOUND_HIT_BLUNT_HEAVY});
isLaunched = vec2_length(body.velocity) >= LAUNCH_END_SPEED;
}
// Remembers where the feet last stood with solid support under both edges, and puts the fighter back there, still and
// in a puff, when it touches a pit.
void Fighter::safe_update(Sim& sim)
{
World& world = sim.world;
Rect box = body.box;
float feet = box.y + box.h;
auto isSupported = [&](float x) {
Tile tile = world.tile_at({x, feet + 1});
return tile == TILE_SOLID || tile == TILE_PLATFORM;
};
if (body.isGrounded && isSupported(box.x) && isSupported(box.x + box.w - 1))
safe = {box.x + box.w / 2, feet};
if (!world.is_touching(box, TILE_PIT))
return;
sim.cues->burst_spawn(LAND_DUST, center_get(), 0, index);
sim.cues->sound_play(SOUND_FALL);
charge_cancel(sim);
body.box.x = safe.x - box.w / 2;
body.box.y = safe.y - box.h;
body.velocity = {};
cornerPrevious = {body.box.x, body.box.y};
isLaunched = false;
sim.cues->burst_spawn(LAND_DUST, center_get(), 0, index);
}
// Where the player touches the wall on the given side, around hand height.
Vec2 Fighter::wall_point_get(int wall)
{
Rect box = body.box;
return {wall > 0 ? box.x + box.w : box.x, box.y + box.h * WALL_POINT_HEIGHT};
}
// Which side a wall touches the player on: -1 left, 1 right, 0 neither.
int Fighter::wall_get(Sim& sim)
{
Rect box = body.box;
float middle = box.y + box.h / 2;
float low = box.y + box.h - 2;
auto isWall = [&](float x) {
return sim.world.is_solid_at({x, middle}) ||
(sim.world.tile_at({x, low}) == TILE_SLOPE && sim.world.is_solid_at({x, low}));
};
if (isWall(box.x - 1))
return -1;
if (isWall(box.x + box.w + 1))
return 1;
return 0;
}
// Attack fires normal attacks, repeating while held, and letting it go sends any gathered Grimoire wisps. Charge
// charges and fires on release, falling back to a normal attack if let go early. Charge lives on the player so it
// survives weapon swaps. A fresh press of either lets go of a grapple, keeping the swing's momentum. Charge calls the
// boomerangs back once they're all out, and either reels in a flail.
void Fighter::charge_update(Sim& sim)
{
Attack* hook = sim.weapons.hook_get(*this);
bool isLeftPressed = is_pressed(CONTROL_ATTACK);
bool isRightPressed = is_pressed(CONTROL_CHARGE);
if (is_released(CONTROL_ATTACK))
sim.weapons.orbits_release(*this);
// A latched grapple lets go when clicked with the grapple in hand. Another weapon fires as it hangs on.
if (hook && hook->key == weapon_key(slot) && (isLeftPressed || isRightPressed))
{
sim.weapons.attacks_release(*this, ATTACK_HOOK);
isFireBlocked = isLeftPressed;
return;
}
if (sim.weapons.is_recalled(sim, *this))
{
isFireBlocked = isLeftPressed;
return;
}
// A full charge let go of during a cooldown goes first, the moment the weapon in hand is ready (see below).
// Attacking while charging spends the charge on the normal attack (as strong as the charge built, short of the
// charged one) once it's ready. A weapon whose charge just strengthens its shot lets it go as if released instead,
// and a Mine's detonator leaves the charge (the next mine) be.
int key = weapon_key(slot);
int weapon = weapon_get();
bool isDetonator = weapon >= 0 && WEAPON_DEFS[weapon].normal.kind == ATTACK_DETONATE;
bool isReady = key < 0 || cooldowns[key] <= 0;
if (isChargeQueued && isReady)
{
sim.weapons.fire(sim, *this, 1);
isChargeQueued = false;
isReady = false;
}
isFireBlocked &= is_down(CONTROL_ATTACK);
if (is_down(CONTROL_ATTACK) && !isFireBlocked && isCharging && isReady && !isDetonator)
{
bool isScaled = weapon >= 0 && WEAPON_DEFS[weapon].isChargeScaled;
sim.weapons.fire(sim, *this, isScaled ? charge : std::min(charge, CHARGE_NORMAL_MAX));
charge_cancel(sim);
}
else if (is_down(CONTROL_ATTACK) && !isFireBlocked)
sim.weapons.fire(sim, *this, 0);
if (isRightPressed && weapon_get() >= 0)
isCharging = true;
if (isCharging && is_down(CONTROL_CHARGE) && charge < 1)
{
float previous = charge;
charge = std::min(1.0f, charge + TICK / CHARGE_TIME * stat_get(STAT_CHARGE_RATE));
if (previous < CHARGE_VISIBLE && charge >= CHARGE_VISIBLE)
sim.cues->charge_play(*this);
if (charge >= 1)
{
sim.cues->charge_stop(*this);
sim.cues->sound_play(SOUND_READY);
sim.cues->burst_spawn(READY_BURST, hand_get(), 0, index);
}
}
// Let go, the charge drops back to nothing and fires. A full charge let go of while the weapon in hand is cooling
// down waits, going the moment it's ready (see above).
if (isCharging && is_released(CONTROL_CHARGE))
{
sim.cues->charge_stop(*this);
bool isCooling = key >= 0 && cooldowns[key] > 0;
if (charge >= 1 && isCooling)
isChargeQueued = true;
else
sim.weapons.fire(sim, *this, charge);
charge = 0;
isCharging = false;
}
}
// How much of a trinket's own cooldown is left, from 1 just used to 0 ready. Wings dashes and Climb Claw kicks
// have one, and every other trinket is always 0. Each Wings copy holds a dash of its own, the last copy's spent first
// and refilled first, and with no copy given it's whether the next dash is ready.
float Fighter::trinket_cooldown_get(int trinket, int copy)
{
if (trinket == TRINKET_WINGS)
{
copy = copy >= 0 ? copy : std::max(0, trinket_count(TRINKET_WINGS) - 1);
return std::clamp((dashCooldown - DASH_COOLDOWN * copy) / DASH_COOLDOWN, 0.0f, 1.0f);
}
if (copy > 0)
return 0;
if (trinket == TRINKET_CLIMBING)
return std::max(0.0f, kickTimer) / KICK_COOLDOWN;
return 0;
}
// Freezes it in hitstop for at least amount, though never for more than a second in a row however much it's hit.
void Fighter::hitstop_add(float amount)
{
hitstop = std::max(hitstop, std::min(amount, HITSTOP_RUN_MAX - hitstopRun));
}
// The radius an Iron Weights slam would blast if the fighter landed at its current speed, or 0 if it isn't falling
// fast enough to slam at all.
float Fighter::pound_area_get()
{
if (!is_trinket_equipped(TRINKET_WEIGHTS) || body.isGrounded || body.velocity.y <= MAX_FALL)
return 0;
float impact = std::min(body.velocity.y / POUND_SPEED, POUND_SCALE_MAX);
return stat_get(STAT_SLAM_AREA) * std::sqrt(impact);
}
// Moves the fighter's middle to centre, keeping its momentum, without drawing it sliding across (teleports).
void Fighter::teleport_to(Vec2 centre)
{
body.box.x = centre.x - body.box.w / 2;
body.box.y = centre.y - body.box.h / 2;
cornerPrevious = {body.box.x, body.box.y};
}
// An outside push, like a blast, recoil or a lunge. It counts as a launch, so releasing jump won't cut it short.
// Crouching halves it (knockback, already halved before it gets here, isn't damped again).
void Fighter::impulse_add(Vec2 impulse)
{
body.velocity = body.velocity + impulse * (isCrouching ? CROUCH_DAMP : 1);
isJumping = false;
}
// A knock from another fighter's hit or blast, softened while crouching. It tops up the speed already going its way
// rather than stacking, so rapid small knocks can't juggle, and a hard enough one launches it (that fighter getting
// the credit for whatever it bounces into).
void Fighter::knock_take(Vec2 knock, int owner)
{
knock = knock * (isCrouching ? CROUCH_DAMP : 1);
float strength = vec2_length(knock);
Vec2 direction = vec2_normalize(knock);
body.velocity = body.velocity + direction * std::max(0.0f, strength - vec2_dot(body.velocity, direction));
isJumping = false;
launcher = owner;
isLaunched |= strength >= LAUNCH_START_SPEED;
}
// A push held briefly without gravity or friction, so it carries even along the ground. Used by dashes and lunges.
void Fighter::burst_add(Vec2 impulse)
{
impulse_add(impulse);
dashTimer = DASH_TIME;
}
// Drops any charge in progress and silences its sound.
void Fighter::charge_cancel(Sim& sim)
{
sim.cues->charge_stop(*this);
charge = 0;
isCharging = false;
isChargeQueued = false;
}
// The weapon in the selected hotbar slot, or -1 if the slot is empty.
int Fighter::weapon_get()
{
return weaponLoadout[slot];
}
// The slot whose cooldown, attacks and Doubler echoes a weapon slot's weapon uses: its own (each copy of a weapon is a
// weapon of its own), or -1 for an empty slot.
int Fighter::weapon_key(int weaponSlot)
{
return weaponLoadout[weaponSlot] < 0 ? -1 : weaponSlot;
}
// A weapon slot's level, its copy's own.
int Fighter::slot_level_get(int weaponSlot)
{
return weaponSlotLevels[weaponSlot];
}
const LevelDef& Fighter::level_get(int weaponSlot)
{
return WEAPON_DEFS[weaponLoadout[weaponSlot]].levels[slot_level_get(weaponSlot)];
}
// Puts an item in a slot (or empties it, with -1) at the item's level.
void Fighter::item_set(ItemKind kind, int itemSlot, int item)
{
loadout_get(kind)[itemSlot] = item;
if (item >= 0)
slot_levels_get(kind)[itemSlot] = item_levels_get(kind)[item];
}
// Every item and every copy at one level, as a match sets them each round.
void Fighter::levels_fill(int weaponLevel, int trinketLevel)
{
std::fill(std::begin(weaponItemLevels), std::end(weaponItemLevels), weaponLevel);
std::fill(std::begin(weaponSlotLevels), std::end(weaponSlotLevels), weaponLevel);
std::fill(std::begin(trinketItemLevels), std::end(trinketItemLevels), trinketLevel);
std::fill(std::begin(trinketSlotLevels), std::end(trinketSlotLevels), trinketLevel);
}
// A trinket stat from the equipped copies (across trinkets, like Shrinker and Embiggener's sizes), or its neutral
// value without any. A lone copy counts exactly as it is. Otherwise, by the stat's stacking, the best copy counts, or
// what each adds beyond neutral adds up (Boots 1.3x and 1.3x make 1.6x, Pebble Helmets' damages add) while what each
// takes away below it multiplies in (so Shrinkers never pass zero). The stat's cap holds the total.
float Fighter::stat_get(TrinketStat stat)
{
const TrinketStatDef& def = TRINKET_STATS[stat];
float best = 0;
float gained = 0;
float kept = 1;
float only = 0;
int count = 0;
for (int i = 0; i < trinketSlots; i++)
{
int trinket = trinketLoadout[i];
float value = trinket >= 0 ? TRINKET_DEFS[trinket].levels[trinketSlotLevels[i]].*def.field : 0;
if (value == 0)
continue;
only = value;
count++;
best = std::max(best, value);
if (value < def.neutral)
kept *= value / def.neutral;
else
gained += value - def.neutral;
}
float total = count == 0 ? def.neutral
: count == 1 ? only
: def.stack == STACK_BEST ? best
: (def.neutral + gained) * kept;
return def.cap > 0 ? std::min(total, def.cap) : total;
}
int Fighter::trinket_count(int trinket)
{
return std::count(trinketLoadout, trinketLoadout + trinketSlots, trinket);
}
bool Fighter::is_trinket_equipped(int trinket)
{
for (int item : trinketLoadout)
if (item == trinket)
return true;
return false;
}
bool Fighter::is_down(Control control)
{
return controls.buttons >> control & 1;
}
// Whether any button went down since the last tick, for readying up while the fighter is frozen. It keeps the held
// buttons up to date, so buttons held over from the last round don't count until pressed again.
bool Fighter::is_any_pressed()
{
bool isPressed = (controls.buttons & ~buttonsPrevious) != 0;
buttonsPrevious = controls.buttons;
return isPressed;
}
bool Fighter::is_pressed(Control control)
{
return is_down(control) && !(buttonsPrevious >> control & 1);
}
bool Fighter::is_released(Control control)
{
return !is_down(control) && buttonsPrevious >> control & 1;
}
// Dual Wield's off-hand weapon slots, one per copy, written to slots: the other slots holding weapons, the ones most
// recently switched away from first, then any never switched to, in slot order. Returns how many.
int Fighter::off_slots_get(int* slots)
{
int held = weapon_key(slot);
int count = 0;
for (int i = 0; i < weaponSlots; i++)
if (weaponLoadout[i] >= 0 && weapon_key(i) != held)
slots[count++] = i;
std::stable_sort(slots, slots + count, [&](int a, int b) { return slotSwitches[a] > slotSwitches[b]; });
return std::min(count, trinket_count(TRINKET_DUAL));
}
// The weapon slot a hand holds: the selected one in the main hand (0), and Dual Wield's in the off-hands (1 up), or -1
// for an off-hand holding nothing.
int Fighter::hand_slot_get(int hand)
{
if (hand == 0)
return slot;
int slots[SLOT_MAX];
return hand <= off_slots_get(slots) ? slots[hand - 1] : -1;
}
// Moves the box alpha of the way through the last tick. Only for drawing.
void Fighter::blend(float alpha)
{
body.box.x = math_lerp(cornerPrevious.x, body.box.x, alpha);
body.box.y = math_lerp(cornerPrevious.y, body.box.y, alpha);
}
Vec2 Fighter::center_get()
{
return {body.box.x + body.box.w / 2, body.box.y + body.box.h / 2};
}
// Where a hand holds its weapon: the main hand (0) in front, and Dual Wield's off-hands (1 up) behind the body, the
// first just behind it and the rest fanned out further back, alternating up and down.
Vec2 Fighter::hand_get(int hand)
{
Vec2 offset = HAND_OFFSET;
if (hand > 0)
{
int step = hand / 2;
offset = OFFHAND_OFFSET + Vec2{-OFFHAND_FAN.x * step, (hand % 2 == 0 ? -1 : 1) * OFFHAND_FAN.y * step};
}
return center_get() + Vec2{is_left() ? -offset.x : offset.x, offset.y} * scale;
}
// Full charge pulses white on and off, shared by the charge bar and the held weapon.
bool Fighter::is_crawling()
{
return isCrouching && !isSliding && std::abs(body.velocity.x) > STEP_SPEED / 2;
}
bool Fighter::is_left()
{
return isFacingLeft;
}