- 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>
907 lines
35 KiB
C++
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;
|
|
}
|