Files
smack-a-brother/src/fighter.cpp
T
shweetandClaude Opus 5.5 9a41474069 Tomatoes, match tabs, shared pings, and a round of fixes
- On the results, everyone but the winner splats tomatoes where they
  click (bots aim at the winner), with a new splat sound.
- During a match the menu has the item tabs (choosing when the
  countdown allows, and otherwise levelling the sandbox's levels for
  when you're back), Play (the game's rules) and Settings.
- Pings are shared through the server (protocol 4), so other players'
  connection bars show over their heads and on their windows.
- PvP comes into lobbies with your profile and is saved.
- The choice menu opens by itself however the countdown began.
- Knocked-out fighters keep their buttons current, so Ready on the
  results toggles once per press.
- The weapon wheel wraps both ways. Readying with empty slots plays
  the equip sound for your own fighter.
- Shock Absorber: up to 50% less weapon movement and 33% less
  knockback. Only the catalog previews the next level.

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

907 lines
35 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;
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_AREAS[LEVEL_COUNT] = {15, 20, 25};
constexpr float POUND_DAMAGES[LEVEL_COUNT] = {20, 35, 50};
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 = {220, -330};
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;
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 = std::max(hitstop, 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) * knock_absorb_get();
knock_take(direction * knock, damage.owner);
if (health > 0)
return;
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.
if (!isAlive)
{
buttonsPrevious = controls.buttons;
return;
}
cornerPrevious = {body.box.x, body.box.y};
flash = std::max(0.0f, flash - FLASH_DECAY * TICK);
shockCooldown -= TICK;
if (hitstop > 0)
{
hitstop -= TICK;
return;
}
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 = trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * std::sqrt(impact),
.damage = trinket_sum_get(TRINKET_WEIGHTS, POUND_DAMAGES) * 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 = trinket_get(TRINKET_BOOTS, 1);
float runSpeed = RUN_SPEED * boots * (1 + trinket_get(TRINKET_SPEEDOMETER, 0)) * (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 ? trinket_get(TRINKET_WEIGHTS, 1) : 1;
float gravity = GRAVITY * (isFastFalling ? FAST_FALL_GRAVITY * weights : 1);
gravity *= isFloating ? trinket_get(TRINKET_FEATHER, 1) : 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 * trinket_get(TRINKET_ABSORBER, 1);
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), and a thrown flail ball is dropped.
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;
sim.weapons.attacks_release(*this, ATTACK_FLAIL);
}
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 = trinket_get(TRINKET_SHRINKER, 1) * trinket_get(TRINKET_EMBIGGENER, 1);
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 = trinket_best_get(TRINKET_WINGS, 0);
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 = trinket_get(TRINKET_CLIMBING, 0);
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;
if (level >= 2 && kickTimer <= 0 && is_pressed(away))
{
wall_push(sim, wall, KICK_VELOCITY);
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.wisps_release(*this);
if (hook && (isLeftPressed || isRightPressed))
{
sim.weapons.attacks_release(*this, ATTACK_HOOK);
isFireBlocked = isLeftPressed;
return;
}
if (sim.weapons.is_recalled(sim, *this))
{
isFireBlocked = isLeftPressed;
return;
}
isFireBlocked &= is_down(CONTROL_ATTACK);
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 * trinket_get(TRINKET_DRINK, 1));
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);
}
}
if (isCharging && is_released(CONTROL_CHARGE))
{
sim.weapons.fire(sim, *this, charge);
sim.cues->charge_stop(*this);
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.
float Fighter::trinket_cooldown_get(int trinket)
{
if (trinket == TRINKET_WINGS)
{
int spare = std::max(0, trinket_count(TRINKET_WINGS) - 1);
return std::clamp((dashCooldown - DASH_COOLDOWN * spare) / DASH_COOLDOWN, 0.0f, 1.0f);
}
if (trinket == TRINKET_CLIMBING)
return std::max(0.0f, kickTimer) / KICK_COOLDOWN;
return 0;
}
// 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 trinket_sum_get(TRINKET_WEIGHTS, POUND_AREAS) * 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, bool isDamped)
{
body.velocity = body.velocity + impulse * (isDamped && 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);
impulse_add(direction * std::max(0.0f, strength - vec2_dot(body.velocity, direction)), 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;
}
// 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(bool isTrinket, int itemSlot, int item)
{
(isTrinket ? trinketLoadout : weaponLoadout)[itemSlot] = item;
if (item >= 0)
(isTrinket ? trinketSlotLevels : weaponSlotLevels)[itemSlot] =
(isTrinket ? trinketItemLevels : weaponItemLevels)[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);
}
// What a trinket does, from its equipped copies, or the neutral value without any. Copies add what each does beyond
// neutral (Boots 1.3x and 1.3x make 1.6x, Pebble Helmets' damages add), except that ones taking something away (below
// a neutral of 1, like Shrinker or Feather) multiply, so they never pass zero; the trinket's cap holds the total.
float Fighter::trinket_get(TrinketId trinket, float neutral)
{
const TrinketDef& def = TRINKET_DEFS[trinket];
float total = neutral;
bool isFirst = true;
for (int i = 0; i < trinketSlots; i++)
{
if (trinketLoadout[i] != trinket)
continue;
// The first copy counts exactly as it is (adding and taking back the neutral value would round it).
float value = def.values[trinketSlotLevels[i]];
total = isFirst ? value : neutral != 0 && value < neutral ? total * value / neutral : total + value - neutral;
isFirst = false;
}
return def.cap > 0 ? std::min(total, def.cap) : total;
}
// The best equipped copy's value, or the neutral value without any.
float Fighter::trinket_best_get(TrinketId trinket, float neutral)
{
int level = trinket_level_get(trinket);
return level < 0 ? neutral : TRINKET_DEFS[trinket].values[level];
}
// A per-level number for a trinket (like Iron Weights' slam damage): the copies' added up, or 0 without any.
float Fighter::trinket_sum_get(int trinket, const float* table)
{
float total = 0;
for (int i = 0; i < trinketSlots; i++)
total += trinketLoadout[i] == trinket ? table[trinketSlotLevels[i]] : 0;
return total;
}
// The best equipped copy's level, or -1 without any.
int Fighter::trinket_level_get(int trinket)
{
int level = -1;
for (int i = 0; i < trinketSlots; i++)
if (trinketLoadout[i] == trinket)
level = std::max(level, trinketSlotLevels[i]);
return level;
}
int Fighter::trinket_count(int trinket)
{
return std::count(trinketLoadout, trinketLoadout + trinketSlots, trinket);
}
// What Shock Absorber leaves of knockback taken: part of the way to what it leaves of weapon movement.
float Fighter::knock_absorb_get()
{
return 1 - (1 - trinket_get(TRINKET_ABSORBER, 1)) * ABSORB_KNOCK_SHARE;
}
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;
}