#include "fighter.h" #include "sim.h" #include 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; }