diff --git a/resources/gfx/trinkets.png b/resources/gfx/trinkets.png index 0ab6a4a..729eb34 100644 Binary files a/resources/gfx/trinkets.png and b/resources/gfx/trinkets.png differ diff --git a/server/server.cpp b/server/server.cpp index b10c180..c4a729b 100644 --- a/server/server.cpp +++ b/server/server.cpp @@ -577,6 +577,15 @@ static void packet_handle(int id, const std::string& bytes) } return; } + // A player out of step with their room gets it again, as when they came in (but already seated). + if (type == PACKET_RESYNC) + { + int serial = 0; + if (!reader.get(serial) || serial != member.room || !rooms.contains(serial)) + return; + std::printf("Lobby %s: %d resynced room %d at tick %d\n", member.code.c_str(), id, serial, rooms[serial].tick); + return room_enter(id, serial, ENTRY_SEATED); + } if (type == PACKET_QUEUE) { Uint8 isOpen = 0; diff --git a/src/fighter.cpp b/src/fighter.cpp index 14d8d10..7c13fea 100644 --- a/src/fighter.cpp +++ b/src/fighter.cpp @@ -9,6 +9,9 @@ constexpr float KNOCK_BASE = 60; constexpr float KNOCK_LIFT = 0.35f; constexpr float KNOCK_MAX = 4500; constexpr float KNOCK_PER_DAMAGE = 30; +// How often a fighter stuck in solid ground loses health, and how much. +constexpr int SUFFOCATE_TICKS = 5; +constexpr int SUFFOCATE_DAMAGE = 1; constexpr float LAUNCH_START_SPEED = 300; constexpr float AFTERIMAGE_SPEED = 440; @@ -241,9 +244,13 @@ void Fighter::update(Sim& sim) sim.world.body_move(body); sim.world.obstacles.clear(); sim.world.ledges.clear(); - if (isLaunched) + // A riding attack's bounce comes first (see Weapons::ride_bounce), so a fighter never bounces twice off one + // surface. + bool isRidden = sim.weapons.ride_bounce(sim, *this, {runSpeed, fallSpeed}); + if (isLaunched && !isRidden) bounce_resolve(sim, {runSpeed, fallSpeed}); safe_update(sim); + suffocation_update(sim); wallMemory -= TICK; bool isWallHit = body.velocity.x == 0 && std::abs(runSpeed) > WALL_IMPACT_SPEED; @@ -518,32 +525,37 @@ void Fighter::wall_push(Sim& sim, int wall, Vec2 base) 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) +// Bounces off whatever stopped it this move (before is the speed it had), keeping keep of its speed into the surface (a +// slope's, or a wall's, ceiling's or floor's). Returns how hard it hit, or 0 when it didn't. +float Fighter::surface_bounce(Vec2 before, float keep) { 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; + body.velocity = before - normal * (into * (1 + keep)); + return -into; } - else + if (body.velocity.x == 0 && std::abs(before.x) > BOUNCE_MIN) { - 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)); - } + body.velocity.x = -before.x * keep; + impact = std::abs(before.x); } + if (body.velocity.y == 0 && std::abs(before.y) > BOUNCE_MIN) + { + body.velocity.y = -before.y * keep; + impact = std::max(impact, std::abs(before.y)); + } + return impact; +} + +// 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 = surface_bounce(before, BOUNCE_KEEP); if (impact > 0) { sim.cues->sound_play(SOUND_BOUNCE); @@ -562,6 +574,23 @@ void Fighter::bounce_resolve(Sim& sim, Vec2 before) isLaunched = vec2_length(body.velocity) >= LAUNCH_END_SPEED; } +// Stuck inside solid ground (its middle in it, however it got there), a fighter suffocates: SUFFOCATE_DAMAGE every +// SUFFOCATE_TICKS, flashing as for any hit but not knocked about. Races and the sandbox cost no health, as with any +// hit. +void Fighter::suffocation_update(Sim& sim) +{ + suffocation = sim.world.is_solid_at(center_get()) ? suffocation + 1 : 0; + if (suffocation == 0 || suffocation % SUFFOCATE_TICKS != 0) + return; + damage_take(sim, {.point = center_get(), + .source = center_get(), + .knock = 0, + .amount = SUFFOCATE_DAMAGE, + .owner = index, + .target = index, + .sound = SOUND_NONE}); +} + // 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) diff --git a/src/fighter.h b/src/fighter.h index f9f8879..9f2cb16 100644 --- a/src/fighter.h +++ b/src/fighter.h @@ -137,6 +137,8 @@ public: // When each weapon slot was last switched away from, counting switches (0 for never), for Dual Wield. int slotSwitches[SLOT_MAX] = {}; int switches = 0; + // How many ticks in a row its middle has been inside solid ground (see suffocation_update). + int suffocation = 0; int trinketItemLevels[TRINKET_COUNT] = {}; int trinketSlotLevels[SLOT_MAX] = {}; int trinketLoadout[SLOT_MAX]; @@ -160,6 +162,8 @@ public: void position_set(Sim& sim, Vec2 feet); void slots_set(int weapons, int trinkets); void damage_take(Sim& sim, Damage damage); + void suffocation_update(Sim& sim); + float surface_bounce(Vec2 before, float keep); void die(Sim& sim); void update(Sim& sim); void blend(float alpha); diff --git a/src/protocol.h b/src/protocol.h index 4a94f56..03516a5 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -27,7 +27,7 @@ #include #include -constexpr int PROTOCOL_VERSION = 11; +constexpr int PROTOCOL_VERSION = 12; constexpr int LOBBY_MEMBERS_MAX = 16; constexpr int LOBBY_CODE_LENGTH = 4; // The longest a lobby's name and password can be. @@ -57,6 +57,8 @@ constexpr int SERVER_URL_LENGTH = 128; // PACKET_PING Uint16 milliseconds this player's round trip to the server // PACKET_WATCH room spectate one of the lobby's games (from its sandbox), // or 0 to go back to the sandbox +// PACKET_RESYNC room this player's game went out of step with the room's: +// send it again // // Server to game: // PACKET_STATE room, tick, state the game this player is in now, as a tick starts @@ -92,7 +94,8 @@ enum PacketType : unsigned char PACKET_CHECKSUM, PACKET_PING, PACKET_ROOMS, - PACKET_WATCH + PACKET_WATCH, + PACKET_RESYNC }; // How a message goes: lobby text over the socket, or game bytes over the data channels, either reliably and in order diff --git a/src/scene.cpp b/src/scene.cpp index f0d4a2d..1be425b 100644 --- a/src/scene.cpp +++ b/src/scene.cpp @@ -935,18 +935,22 @@ void Scene::attacks_draw() renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA)); // A Vacuum's cone is outlined: steady while it sucks, fading as an airblast passes. // Its fill fades toward the far end, as its strength does. - if (is_cone(*attack.def())) + // Bounced off walls by Ricochet, it's drawn as each cone it glances into. + Attack cones[CONES_MAX]; + int count = is_cone(*attack.def()) ? cones_get(game.sim, attack, cones) : 0; + for (int c = 0; c < count; c++) { - Vec2 tip = attack.position + vec2_polar(attack.aimAngle, cone_reach_get(attack)); + Attack& cone = cones[c]; + Vec2 tip = cone.position + vec2_polar(cone.aimAngle, cone_reach_get(cone)); float alpha = kind == ATTACK_GUST ? 1 - progress : 1; - cone_fill_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_FILL_ALPHA)); + cone_fill_draw(renderer, cone.position, tip, cone_get(cone), color_fade(tint, alpha * CONE_FILL_ALPHA)); if (kind == ATTACK_VACUUM) - suck_draw(attack, tint); + suck_draw(cone, tint); else - gust_draw(attack, tint); - cone_outline_draw(renderer, attack.position, tip, VACUUM_CONE, color_fade(tint, alpha * CONE_ALPHA)); + gust_draw(cone, tint); + cone_outline_draw(renderer, cone.position, tip, cone_get(cone), color_fade(tint, alpha * CONE_ALPHA)); } - for (int i = 0; i < attack.bounces && kind != ATTACK_BEAM && !attack.isAttached; i++) + for (int i = 0; i < attack.bounces && !attack.def()->isInstant && count == 0 && !attack.isAttached; i++) { float angle = attack.time * PIP_SPIN + 2 * PI * i / attack.bounces; Vec2 pip = attack.position + vec2_polar(angle, attack.radius + PIP_GAP); @@ -1001,8 +1005,8 @@ void Scene::suck_draw(Attack& attack, Color color) Uint32 seed = ((Uint32)std::floor(phase) * SUCK_MOTES + i) * MOTE_KEYS + attack.owner; float side = mote_share_get(seed) * 2 - 1; float fade = std::min({1.0f, progress / SUCK_FADE_IN, (1 - progress) / SUCK_FADE_OUT}); - Vec2 mote = - attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * (1 - progress)); + Vec2 mote = attack.position + + vec2_polar(attack.aimAngle + side * cone_get(attack), cone_reach_get(attack) * (1 - progress)); Particle particle = {.color = color, .life = fade, .lifeMax = 1, .size = SUCK_MOTE_SIZE}; particle_draw(game.renderer, particle, mote); } @@ -1023,8 +1027,8 @@ void Scene::gust_draw(Attack& attack, Color color) float delay = GUST_STAGGER * mote_share_get(seed + 2); float own = std::clamp((progress - delay) / (1 - delay), 0.0f, 1.0f); float sweep = 1 - (1 - own) * (1 - own); - Vec2 mote = - attack.position + vec2_polar(attack.aimAngle + side * VACUUM_CONE, cone_reach_get(attack) * reach * sweep); + Vec2 mote = attack.position + + vec2_polar(attack.aimAngle + side * cone_get(attack), cone_reach_get(attack) * reach * sweep); Particle particle = {.color = color, .life = 1 - own, .lifeMax = 1, .size = SUCK_MOTE_SIZE}; particle_draw(game.renderer, particle, mote); } @@ -1055,10 +1059,16 @@ void Scene::collision_draw() capsules_fill(renderer, shapes, count, color); if (cone_get(attack) <= 0) continue; - float range = cone_reach_get(attack); - Rect bounds = {attack.position.x - range, attack.position.y - range, attack.position.x + range, - attack.position.y + range}; - shape_fill(renderer, bounds, [&](Vec2 point) { return is_in_cone(attack, point); }, color); + Attack cones[CONES_MAX]; + int coneCount = cones_get(game.sim, attack, cones); + for (int c = 0; c < coneCount; c++) + { + Attack& cone = cones[c]; + float range = cone_reach_get(cone); + Rect bounds = {cone.position.x - range, cone.position.y - range, cone.position.x + range, + cone.position.y + range}; + shape_fill(renderer, bounds, [&](Vec2 point) { return is_in_cone(cone, point); }, color); + } } for (int i = 0; i < game.sim.fighterCount; i++) if (game.sim.fighters[i].isAlive) diff --git a/src/session.cpp b/src/session.cpp index 68592b6..de67531 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -128,6 +128,13 @@ void Session::update(Game& game, Controls local) } check = checksums.erase(check); } + // Out of step, it asks for the room again (at most every RESYNC_WAIT), which puts it right (see state_load). + if (isDesynced && SDL_GetTicks() >= nextResync) + { + nextResync = SDL_GetTicks() + RESYNC_WAIT; + game.net.send(PacketWriter().put(PACKET_RESYNC).put(room).bytes, DELIVERY_RELIABLE); + game.chat.system("Out of step with the server. Resyncing..."); + } int advance = 1; if (tick - paced >= PACE_WAIT && (lead < LEAD_LOW || lead > LEAD_HIGH)) diff --git a/src/session.h b/src/session.h index bbbaab9..ab0c61f 100644 --- a/src/session.h +++ b/src/session.h @@ -10,6 +10,8 @@ struct Game; constexpr int SESSION_RING = 64; // How often (in milliseconds) this screen tells the lobby its round trip. constexpr Uint64 PING_REPORT_INTERVAL = 2000; +// The least time between asking for the room again while out of step with it. +constexpr Uint64 RESYNC_WAIT = 2000; // A member's connection has no ping to show. constexpr float PING_NONE = -2; @@ -118,6 +120,8 @@ private: Uint64 nextRename = 0; bool isRenameWaiting = false; Uint64 nextPingReport = 0; + // When this screen may next ask for its room again, out of step (see PACKET_RESYNC). + Uint64 nextResync = 0; void start(Game& game); void packets_take(Game& game); diff --git a/src/sim.cpp b/src/sim.cpp index bee64e3..8b736a8 100644 --- a/src/sim.cpp +++ b/src/sim.cpp @@ -136,12 +136,20 @@ Uint32 Sim::checksum_get() { Uint32 hash = FNV_OFFSET; hash_add(hash, (float)match.state + match.timer); + for (float value : {(float)match.round, (float)match.winner, match.clock}) + hash_add(hash, value); for (int i = 0; i < fighterCount; i++) { Fighter& fighter = fighters[i]; for (float value : {fighter.body.box.x, fighter.body.box.y, fighter.body.velocity.x, fighter.body.velocity.y, fighter.aim.x, fighter.aim.y, fighter.charge, fighter.health, fighter.hitstop}) hash_add(hash, value); + for (int value : {(int)fighter.isAlive, (int)fighter.isLaunched, fighter.slot, fighter.launcher, match.wins[i]}) + hash_add(hash, (float)value); + Queue& queue = match.queues[i]; + if (queue.isActive) + for (float value : {queue.area.x, queue.area.y, queue.area.w, (float)queue.rules.players}) + hash_add(hash, value); for (float cooldown : fighter.cooldowns) hash_add(hash, cooldown); // What the menu's edits change: loadouts, levels, slot counts, stacking and PvP. @@ -158,7 +166,11 @@ Uint32 Sim::checksum_get() } for (Attack& attack : weapons.attacks) for (float value : {attack.position.x, attack.position.y, attack.velocity.x, attack.velocity.y, attack.angle, - attack.time, (float)attack.hitsLeft}) + attack.time, (float)attack.hitsLeft, (float)attack.owner, attack.bonus, + (float)attack.ricochets, attack.lifetime, (float)attack.isReturning}) + hash_add(hash, value); + for (Attack& attack : weapons.pending) + for (float value : {attack.position.x, attack.position.y, attack.stats.damage}) hash_add(hash, value); for (Dummy& dummy : world.dummies) for (float value : {dummy.flash, dummy.wobble, dummy.wobbleSpeed}) diff --git a/src/state.cpp b/src/state.cpp index 26df114..5d01aef 100644 --- a/src/state.cpp +++ b/src/state.cpp @@ -117,6 +117,7 @@ template static void state_walk(Stream& stream, Sim stream.raw(sim.bot.states, MAX_FIGHTERS); stream.one(sim.weapons.orbitClock); stream.list(sim.weapons.attacks); + stream.list(sim.weapons.pending); World& world = sim.world; stream.list(world.dummies); stream.list(world.ledges); diff --git a/src/trinket.h b/src/trinket.h index 59de1d9..df289a7 100644 --- a/src/trinket.h +++ b/src/trinket.h @@ -209,7 +209,7 @@ inline constexpr TrinketDef TRINKET_DEFS[TRINKET_COUNT] = { {"The Good Stuff", "Increases spread on attacks.", {{.spread = 1.2f}, {.spread = 1.5f}, {.spread = 2}}, - "Steady hands are for\nsurgeons."}, + "Aye, me bottle o'scrumpy!"}, {"Splitter", "Attacks split into smaller,\nless-damaging copies.", {{.copies = 2, .copyDamage = 1 / 3.0f}, diff --git a/src/weapon.cpp b/src/weapon.cpp index 9c0a4c9..61d40ea 100644 --- a/src/weapon.cpp +++ b/src/weapon.cpp @@ -35,6 +35,8 @@ constexpr float KNOCKBACK_REACH = 1.5f; constexpr float KNOCKBACK_SPEED = 550; constexpr float LANCE_LENGTH = 40; constexpr float MUZZLE_DISTANCE = 18; +// The share of the way a loose attack heading home turns to face away from its fighter each tick. +constexpr float HOME_TURN = 0.2f; constexpr float PROJECTILE_STEP = 8; constexpr float RAY_STEP = 2; constexpr float RECOIL_CURVE = 0.75f; @@ -70,7 +72,6 @@ constexpr float SPLIT_SCALE = 0.75f; constexpr float SPLIT_GAP = 0.06f; constexpr float SPLIT_GAP_MELEE = 0.5f; constexpr float SPLIT_GAP_THROWN = 0.3f; -constexpr float UPPERCUT_REACH = 16; constexpr float VOLLEY_GAP = 6; constexpr float WHIP_CONE = 0.25f; constexpr float WHIP_HANDLE = 4; @@ -114,7 +115,8 @@ constexpr float VACUUM_SPIN_DOWN = 0.2f; constexpr float REST_SPEED = 40; // How fast a shot flies home, against the speed it left at. constexpr float RETURN_SHARE = 0.5f; -// How hard a bouncing shot must hit a surface to bounce off it. +// How hard a bouncing shot must hit a surface to bounce off it, and how hard one with Ricochet bounces left at least +// comes off whatever it touches. constexpr float SHOT_BOUNCE_MIN = 60; // How much of its blast a grenade deals to whoever it clonks on the way. constexpr float GRENADE_CONTACT_SHARE = 0.2f; @@ -235,16 +237,17 @@ static void impact_play(Sim& sim, AttackKind kind) } } -// Spends one of an attack's ricochets on a bounce, adding to its later damage. Flames go quietly, as there are so -// many. -static void ricochet_count(Sim& sim, Attack& attack) +// Spends one of an attack's ricochets on a bounce at point, adding to its later damage and giving it its hits back, so +// it can strike anyone again straight away. +static void ricochet_count(Sim& sim, Attack& attack, Vec2 point) { attack.bounces--; attack.ricochets++; - if (attack.def()->kind == ATTACK_FLAME) - return; + attack.hitsLeft = std::max(attack.hitsLeft, attack.def()->hits); + attack.struck = 0; + attack.hitCooldown = 0; sim.cues->sound_play(SOUND_RICOCHET); - sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); + sim.cues->burst_spawn(IMPACT_BURST, point, 0, attack.owner); } // Turns a projectile off the wall it's about to enter, starting its range, lifetime and damage falloff afresh from @@ -255,7 +258,7 @@ static void ricochet(Sim& sim, Attack& attack, Vec2 next) attack.angle = vec2_angle(attack.velocity); attack.origin = attack.position; attack.lifeStart = attack.time; - ricochet_count(sim, attack); + ricochet_count(sim, attack, attack.position); } // What stops an attack moving through the world: walls and platform tops for most, walls alone for wisps. @@ -551,6 +554,89 @@ static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked) return point; } +// Where a ray from start along angle sets off: start, or from inside a wall, back out where it went in (looking as far +// back as the hand behind a muzzle), so it glances off the wall's face instead of bouncing about inside it. +static Vec2 ray_start_get(Sim& sim, Vec2 start, float angle) +{ + auto isOpen = [&](Vec2 point) { + return !sim.world.is_blocking_at(point); + }; + return ray_march(start, angle + PI, MUZZLE_DISTANCE, isOpen); +} + +// Turns a ray's segment off the wall its tip ran into: it sets off again from just short of the wall the way the wall +// turns it, reaching range or the next wall. +static void strand_turn(Sim& sim, Attack& segment, float range) +{ + auto isBlocked = [&](Vec2 point) { + return sim.world.is_blocking_at(point); + }; + Vec2 before = segment.tip - vec2_polar(segment.angle, RAY_STEP); + segment.angle = vec2_angle(reflect_get(sim, before, segment.tip, vec2_polar(segment.angle, 1))); + segment.position = before; + segment.tip = ray_march(before, segment.angle, range, isBlocked); +} + +// A cone's corners, returning how many: from its start (steps[0]) out to its tip (steps[1]), cut short where its aim +// meets a wall for each bounce it has, the rest of its reach glancing off the wall. Each corner starts a cone, and the +// last is its tip. +static int cone_steps_get(Sim& sim, Vec2* steps, int bounces) +{ + auto isBlocked = [&](Vec2 point) { + return sim.world.is_blocking_at(point); + }; + if (bounces > 0) + steps[0] = ray_start_get(sim, steps[0], vec2_angle(steps[1] - steps[0])); + int count = 2; + for (; count - 2 < std::min(bounces, CONES_MAX - 1); count++) + { + Vec2 start = steps[count - 2]; + float angle = vec2_angle(steps[count - 1] - start); + float reach = vec2_length(steps[count - 1] - start); + Vec2 wall = ray_march(start, angle, reach, isBlocked); + if (!isBlocked(wall) || vec2_length(wall - start) >= reach) + break; + Vec2 before = wall - vec2_polar(angle, RAY_STEP); + float turned = vec2_angle(reflect_get(sim, before, wall, vec2_polar(angle, 1))); + steps[count - 1] = before; + steps[count] = before + vec2_polar(turned, reach - vec2_length(before - start)); + } + return count; +} + +// The cones an attack's cone makes right now, returning how many: itself, and with Ricochet bounces, cut short where +// its aim meets a wall to go on from there as another cone glancing off it, a bounce harder (see cone_steps_get). The +// attack's own ricochets are how many walls it glanced off last tick (see cones_glance). +int cones_get(Sim& sim, Attack& attack, Attack* cones) +{ + Vec2 steps[CONES_MAX + 1] = {attack.position, + attack.position + vec2_polar(attack.aimAngle, cone_reach_get(attack))}; + int count = cone_steps_get(sim, steps, attack.bounces) - 1; + cones[0] = attack; + cones[0].ricochets = 0; + for (int i = 0; i < count && count > 1; i++) + { + cones[i] = attack; + cones[i].position = steps[i]; + cones[i].aimAngle = vec2_angle(steps[i + 1] - steps[i]); + cones[i].stats.range = vec2_length(steps[i + 1] - steps[i]); + cones[i].length = 1; + cones[i].ricochets = i; + } + return count; +} + +// An attack's cones for this tick (see cones_get), ricocheting each time its cone glances off one more wall than it +// did. +static int cones_glance(Sim& sim, Attack& attack, Attack* cones) +{ + int count = cones_get(sim, attack, cones); + if (count - 1 > attack.ricochets) + sim.cues->sound_play(SOUND_RICOCHET); + attack.ricochets = count - 1; + return count; +} + // The capsules an attack hits with right now, returning how many. A plain whip fans rays across its cone from the // hand, out as far ahead as its lash reaches and short of walls. Everything else is one capsule from position to tip. int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) @@ -1071,6 +1157,7 @@ void Weapons::attack_move(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); Fighter& fighter = sim.fighters[attack.owner]; + Vec2 lastTip = attack.tip; float progress = attack.progress_get(); float range = attack.stats.range; attack.radius = def.shape == SHAPE_CIRCLE ? attack.stats.range : def.radius * attack.scale; @@ -1120,16 +1207,6 @@ void Weapons::attack_move(Sim& sim, Attack& attack) } case ATTACK_PUNCH: break; - case ATTACK_UPPERCUT: - { - // The fist rises beside its fighter on the aimed side, carried up with them. Splitter's smaller copies - // keep the full reach so they fan out around the fighter instead of piling up on it. - float reach = UPPERCUT_REACH * attack.scale; - if (fighter.stat_get(STAT_COPIES) > 1) - reach /= SPLIT_SCALE; - attack.position = attack.origin + vec2_polar(attack.aimAngle, reach); - break; - } case ATTACK_POKE: { // Its path carries the tip, never closer in than the lance's own length, so the whole lance stays out in @@ -1168,12 +1245,15 @@ void Weapons::attack_move(Sim& sim, Attack& attack) case ATTACK_WHIP: { // A charged crack stays strung from the end of the handle in the hand to where it cracked, hitting at - // once. A plain lash goes with the hand, as long as its reach along the aim (short of any wall), its handle - // and links moving as lash_get says. It hits anywhere in its cone as far as it reaches until it cracks. + // once (each Ricochet bounce a strand of its own, staying where it glanced). A plain lash goes with the + // hand, as long as its reach along the aim (short of any wall), its handle and links moving as lash_get + // says. It hits anywhere in its cone as far as it reaches until it cracks. if (attack.is_charged()) { - attack.origin = fighter.hand_get(attack.hand); attack.isDead = progress >= 1; + if (attack.ricochets > 0) + break; + attack.origin = fighter.hand_get(attack.hand); attack.angle = vec2_angle(attack.tip - attack.origin); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); break; @@ -1182,6 +1262,26 @@ void Weapons::attack_move(Sim& sim, Attack& attack) return sim.world.is_blocking_at(point); }; Vec2 reach = ray_march(attack.origin, attack.aimAngle, reach_at_get(attack), isBlocked); + // As it cracks against a wall, the reach the wall cut off glances off it with each Ricochet bounce, a + // strand like a charged crack's, hitting as it appears (see strands_spawn). + bool isCracking = progress >= PATH_OUT && progress - TICK / attack.lifetime < PATH_OUT; + if (isCracking && attack.bounces > 0 && isBlocked(reach)) + { + Attack strand = attack; + strand.variant = VARIANT_CHARGED; + strand.stats.lifetime = 0; + strand.stats.range = reach_at_get(attack) - vec2_length(reach - attack.origin); + strand.duration = strand.def()->duration; + strand.hitsLeft = def.hits; + strand.hitCooldown = 0; + strand.time = 0; + strand.timePrevious = 0; + strand.lifeStart = 0; + strand.angle = attack.aimAngle; + strand.tip = reach; + strands_spawn(sim, strand, pending); + attack.bounces = 0; + } attack.angle = vec2_angle(lash_direction_get(attack, lash_heading_get(progress))); attack.position = attack.origin + vec2_polar(attack.angle, WHIP_HANDLE); attack.length = std::max(0.0f, vec2_length(reach - attack.origin) - WHIP_HANDLE); @@ -1227,12 +1327,15 @@ void Weapons::attack_move(Sim& sim, Attack& attack) if (!isFlying && def.kind != ATTACK_POKE && def.kind != ATTACK_BEAM && def.kind != ATTACK_WHIP) attack.tip = attack.position; attack.isHeld = false; + if (def.anchor != ANCHOR_WORLD) + anchor_bounce(sim, attack, lastTip); } -// Places an anchored attack along its path from its anchor (its fighter's hand or middle) for its age, ending it once -// its lifetime is up: as far out along its heading as its speed has carried it (an eased one speeding up as it goes), -// out and back, or as far round its arc. One moved by its own code (a Vacuum's cone) just stays at the muzzle along its -// fighter's aim, keeping whatever turn it set off with (a Splitter copy's place in the fan), its own code ending it. +// Places an anchored attack along its path from its anchor (its fighter's hand or middle, or once loose, the wall it +// glanced off) for its age, ending it once its lifetime is up: as far out along its heading as its speed has carried it +// (an eased one speeding up as it goes), out and back, or as far round its arc. One moved by its own code (a Vacuum's +// cone) just stays at the muzzle along its fighter's aim, keeping whatever turn it set off with (a Splitter copy's +// place in the fan), its own code ending it. void Weapons::path_move(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); @@ -1248,21 +1351,86 @@ void Weapons::path_move(Sim& sim, Attack& attack) return; } float age = attack.life_get(); + Vec2 anchor = def.anchor == ANCHOR_HAND ? fighter.hand_get(attack.hand) : fighter.center_get(); + // Done going out and back from the walls it glanced off, it heads home: the last wall it went from comes back to + // its anchor as fast as it would have come back, and it turns to face away from its fighter as it goes. + if (attack.glance == GLANCE_LOOSE && def.path == PATH_RETURN && age >= attack.lifetime) + attack.glance = GLANCE_HOME; + if (attack.glance == GLANCE_HOME) + { + Vec2 away = attack.origin - anchor; + float left = vec2_length(away) - attack.stats.speed * PATH_OUT / (1 - PATH_OUT) * TICK; + attack.angle = angle_lerp(attack.angle, vec2_angle(away), HOME_TURN); + attack.aimAngle = attack.angle; + attack.origin = anchor + vec2_normalize(away) * std::max(0.0f, left); + attack.position = attack.origin; + attack.isDead = left <= 0; + return; + } float out = PATH_OUT * attack.lifetime; float travel = def.isEased ? age * age / attack.lifetime : age; if (def.path == PATH_RETURN && age > out) travel = std::max(0.0f, out * (attack.lifetime - age) / (attack.lifetime - out)); + // Splitter's smaller copies keep the full offset, so they fan out around the anchor instead of piling up on it. + float offset = def.offset * attack.scale / (fighter.stat_get(STAT_COPIES) > 1 ? SPLIT_SCALE : 1); float distance = attack.stats.speed * travel; if (def.path == PATH_ARC) { attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * (ARC_START + distance / attack.stats.range); distance = attack.stats.range; } - attack.origin = def.anchor == ANCHOR_HAND ? fighter.hand_get(attack.hand) : fighter.center_get(); - attack.position = attack.origin + vec2_polar(attack.angle, distance); + if (attack.glance == GLANCE_NONE) + attack.origin = anchor; + attack.position = attack.origin + vec2_polar(attack.aimAngle, offset) + vec2_polar(attack.angle, distance); attack.isDead = age >= attack.lifetime; } +// A bouncy anchored attack whose reach (from its anchor out to its tip) runs into a wall glances off it while it has +// Ricochet bounces, hitting harder for each: an arc (a swing, a spin) sweeps back the other way from where its head was +// last (last), and anything else (a punch, a poke, a lash) comes loose to go again from the wall the way it turns it, +// the wall standing in for its anchor (see Glance). A riding one leaves its bounces to its fighter (see ride_bounce), a +// cone glances as it goes (see cones_get), and a lash as it cracks (see attack_move). +void Weapons::anchor_bounce(Sim& sim, Attack& attack, Vec2 last) +{ + const AttackDef& def = *attack.def(); + // An arc needs where its head was last, so it can't glance until it's been somewhere. + bool isFresh = attack.time == 0 && def.path == PATH_ARC; + bool isSpent = attack.bounces <= 0; + bool isOwnGlance = def.isRiding || is_cone(def) || def.kind == ATTACK_WHIP; + if (!def.isBouncy || isOwnGlance || isSpent || isFresh) + return; + auto isBlocked = [&](Vec2 point) { + return sim.world.is_blocking_at(point); + }; + float angle = vec2_angle(attack.tip - attack.origin); + Vec2 wall = ray_march(attack.origin, angle, vec2_length(attack.tip - attack.origin) + RAY_STEP, isBlocked); + if (!isBlocked(wall) || isBlocked(attack.origin)) + return; + Vec2 before = wall - vec2_polar(angle, RAY_STEP); + float turned = vec2_angle(reflect_get(sim, before, wall, vec2_polar(angle, 1))); + attack.isDead = false; + if (def.path == PATH_ARC) + { + attack.isLeft = !attack.isLeft; + attack.angle = vec2_angle(last - attack.origin); + attack.aimAngle = attack.angle - (attack.isLeft ? -1 : 1) * ARC_START; + attack.position = last; + attack.tip = last; + attack.lifeStart = attack.time; + } + else + { + attack.glance = GLANCE_LOOSE; + attack.angle = turned; + attack.aimAngle = turned; + attack.origin = before; + attack.position = before; + attack.tip = before; + attack.lifeStart = attack.time; + } + ricochet_count(sim, attack, before); +} + // Which way is off the surface a bolt landed on at wall, coming from where it is: a slope's own normal, or else the // side of the wall it ran into. static Vec2 surface_normal_get(Sim& sim, Vec2 from, Vec2 wall) @@ -1304,6 +1472,23 @@ void Weapons::flight_move(Sim& sim, Attack& attack) sim.cues->sound_play(SOUND_WISP); } attack.isReturning |= def.path == PATH_RETURN && attack.time >= attack.restTime + def.hold; + // Heading home with Ricochet bounces left, it glances off a wall in its way like any shot, flying out from it + // afresh until it heads home again. + if (attack.isReturning && attack.bounces > 0) + { + float pace = attack.stats.speed * (def.isTethered ? 1 : RETURN_SHARE); + Vec2 home = vec2_normalize(fighter.hand_get(attack.hand) - attack.position) * pace; + Vec2 wall; + if (is_wall_reached(sim, attack, attack.position + home * TICK, wall)) + { + attack.velocity = home; + attack.isReturning = false; + attack.restTime = LIFETIME_INFINITE; + ricochet(sim, attack, wall); + } + else + attack.position = start; + } if (attack.isReturning && def.isTethered) attack.isDead = is_caught(fighter, attack); else if (attack.isReturning) @@ -1483,29 +1668,34 @@ void Weapons::body_bounce(Sim& sim, Attack& attack) body.velocity.x += def.gravity * body.slope / (1 + body.slope * body.slope) * TICK; Vec2 before = body.velocity; sim.world.body_move(body); + // With Ricochet bounces left, any touch bounces it, coming off at a hop's speed at least, so even one at rest keeps + // ricocheting until they're spent. + bool isRicochet = attack.bounces > 0; + float least = isRicochet ? 0 : SHOT_BOUNCE_MIN; + float hop = isRicochet ? SHOT_BOUNCE_MIN : 0; bool isBounced = false; bool isRamp = body.normal.x != 0 || body.normal.y != 0; float into = vec2_dot(before, body.normal); - if (isRamp && into < -SHOT_BOUNCE_MIN) + if (isRamp && into < -least) { - body.velocity = before - body.normal * (into * (1 + def.bounce)); + body.velocity = before - body.normal * into + body.normal * std::max(-into * def.bounce, hop); body.isGrounded = false; isBounced = true; } - if (!isRamp && body.velocity.x == 0 && std::abs(before.x) > SHOT_BOUNCE_MIN) + if (!isRamp && body.velocity.x == 0 && std::abs(before.x) > least) { - body.velocity.x = -before.x * def.bounce; + body.velocity.x = -std::copysign(std::max(std::abs(before.x) * def.bounce, hop), before.x); isBounced = true; } - if (!isRamp && body.velocity.y == 0 && std::abs(before.y) > SHOT_BOUNCE_MIN) + if (!isRamp && body.velocity.y == 0 && std::abs(before.y) > least) { - body.velocity.y = -before.y * def.bounce; + body.velocity.y = -std::copysign(std::max(std::abs(before.y) * def.bounce, hop), before.y); body.isGrounded = false; isBounced = true; } if (isBounced && attack.bounces > 0) { - ricochet_count(sim, attack); + ricochet_count(sim, attack, attack.position); attack.lifeStart = attack.time; } else if (isBounced) @@ -1756,6 +1946,24 @@ void Weapons::orbits_release(Fighter& fighter) } } +// A riding attack takes its Ricochet bounces with its fighter: when the fighter runs into something (before is the +// speed it had), the first of its riding attacks with a bounce left glances it off, keeping its speed, and takes the +// bounce, its damage raised and its time renewed. Returns whether one did. +bool Weapons::ride_bounce(Sim& sim, Fighter& fighter, Vec2 before) +{ + for (Attack& attack : attacks) + { + if (attack.owner != fighter.index || !attack.def()->isRiding || attack.bounces <= 0 || attack.isDead) + continue; + if (fighter.surface_bounce(before, 1) <= 0) + return false; + attack.lifeStart = attack.time; + ricochet_count(sim, attack, attack.position); + return true; + } + return false; +} + // Grimoire teleport: sparkles round the fighter while it gathers, then moves them to the spot marked when it was cast // (backed off again if their box no longer fits), keeping all their momentum, with a blast there that hurts without // knocking anyone about. A Doubler's echoes teleport again, each to where the cursor pointed as it was cast. An @@ -1868,10 +2076,19 @@ void Weapons::tesla_update(Sim& sim) } // The paths the held weapon's attack would take if fired now (one per Splitter copy), out to the first wall it can't -// ricochet off (Ricochet bounces it off as many as the shot would) or the attack's reach: curving under gravity for -// arrows, straight for other shots, throws, beams, lashes, mines and hooks, and a teleport's line to where it lands. -// Anything that explodes rings the blast it would make where the path ends (its reach counts from the hand). Melee, -// flames and flails have none, as their paths aren't lines. +// ricochet off (Ricochet bounces it off as many as the shot would) or the attack's reach: curving under gravity (and +// slowing with drag) for anything that falls (arrows, bolts, grenades, a thrown flail ball), straight for other shots, +// throws, beams, lashes, mines, hooks and a flying slash, and a teleport's line to where it lands. Anything that +// explodes where it stops rings the blast it would make there (its reach counts from the hand). A bouncing shot (a +// grenade) ends its path where it first lands. Melee, flames and wisps have none, as their paths aren't lines. +// Adds a straight stretch of a path, from one point up to (not including) another, a step at a time. +static void path_line_add(Path& path, Vec2 from, Vec2 to) +{ + float length = vec2_length(to - from); + for (float traveled = 0; traveled < length; traveled += TRAJECTORY_STEP) + path.steps.push_back(from + (to - from) * (traveled / length)); +} + std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) { std::vector paths; @@ -1891,9 +2108,7 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) Vec2 spot = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range); Vec2 from = fighter.center_get(); Path path = {.area = stats.area * def.scale * fighter.stat_get(STAT_ATTACK_SIZE)}; - float length = vec2_length(spot - from); - for (float traveled = 0; traveled < length; traveled += TRAJECTORY_STEP) - path.steps.push_back(from + (spot - from) * (traveled / length)); + path_line_add(path, from, spot); path.steps.push_back(spot); paths.push_back(path); return paths; @@ -1907,36 +2122,62 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) for (int split = 0; split < splits; split++) { float offset = splits > 1 ? split_offset(kind, split, splits, fighter.stat_get(STAT_SPREAD)) : 0; - Vec2 tip = muzzle + vec2_polar(vec2_angle(fighter.aim) + offset, reach); - paths.push_back({.steps = {muzzle, tip}, .cone = VACUUM_CONE * fighter.stat_get(STAT_SPREAD)}); + Vec2 steps[CONES_MAX + 1] = {muzzle, muzzle + vec2_polar(vec2_angle(fighter.aim) + offset, reach)}; + int count = cone_steps_get(sim, steps, def.isBouncy ? fighter.stat_get(STAT_RICOCHETS) : 0); + for (int i = 0; i + 1 < count; i++) + paths.push_back( + {.steps = {steps[i], steps[i + 1]}, .cone = VACUUM_CONE * fighter.stat_get(STAT_SPREAD)}); } return paths; } - bool isArrow = kind == ATTACK_ARROW; + auto isBlocked = [&](Vec2 point) { + return sim.world.is_blocking_at(point); + }; + bool isFalling = def.gravity > 0; bool isStraight = kind == ATTACK_PROJECTILE || kind == ATTACK_BULLET || kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_BEAM || kind == ATTACK_WHIP || kind == ATTACK_MINE || - kind == ATTACK_BOOMERANG || (kind == ATTACK_HOOK && variant != VARIANT_CHARGED); - if (!isArrow && !isStraight) + kind == ATTACK_BOOMERANG || kind == ATTACK_HOOK || + (kind == ATTACK_SLASH && variant == VARIANT_CHARGED); + if (!isFalling && !isStraight) return paths; // Each Splitter copy gets its own path, fanned like the copies. Straight paths step along the aim one unit at a - // time. Arrows fly at their real speed so gravity bends them. + // time. Falling ones fly at their real speed so gravity bends them. + bool isBouncing = def.wall == WALL_BOUNCE; float area = stats.area * def.scale * fighter.stat_get(STAT_ATTACK_SIZE) * (splits > 1 ? SPLIT_SCALE : 1); - float reach = std::min(stats.range, TRAJECTORY_REACH); + float range = reach_get(def.path, stats); + float reach = std::min(range, TRAJECTORY_REACH); for (int split = 0; split < splits; split++) { float offset = splits > 1 ? split_offset(kind, split, splits, fighter.stat_get(STAT_SPREAD)) : 0; Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + offset, 1); float speed = stats.speed * fighter.stat_get(STAT_PROJECTILE_SPEED) + push_get(fighter, aim); - Vec2 velocity = isArrow ? aim * speed : aim; + Vec2 velocity = isFalling ? aim * speed : aim; Attack probe = {.origin = fighter.hand_get(), .angle = vec2_angle(aim), .weapon = fighter.weaponLoadout[key], .isLeft = fighter.is_left()}; Vec2 point = muzzle_get(probe); - int bounces = def.isBouncy ? (int)fighter.stat_get(STAT_RICOCHETS) : 0; + int bounces = def.isBouncy && !isBouncing ? (int)fighter.stat_get(STAT_RICOCHETS) : 0; Path path; - bool isEnded = stats.range <= TRAJECTORY_REACH; + // An instant one reaches its whole range at once from where it sets off (backed out of any wall), and its whole + // range again off each wall it glances off, as instant_launch has it. + if (def.isInstant) + { + Vec2 start = ray_start_get(sim, kind == ATTACK_WHIP ? probe.origin : point, probe.angle); + probe.tip = ray_march(start, probe.angle, range, isBlocked); + path_line_add(path, start, probe.tip); + for (; bounces > 0 && isBlocked(probe.tip); bounces--) + { + strand_turn(sim, probe, range); + path_line_add(path, probe.position, probe.tip); + } + path.steps.push_back(probe.tip); + path.area = area; + paths.push_back(path); + continue; + } + bool isEnded = range <= TRAJECTORY_REACH; for (float traveled = area > 0 ? MUZZLE_DISTANCE : 0; traveled < reach; traveled += TRAJECTORY_STEP) { float time = TRAJECTORY_STEP / std::max(1.0f, vec2_length(velocity)); @@ -1950,11 +2191,12 @@ std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) continue; } velocity.y += def.gravity * time; + velocity = isFalling ? velocity * math_pow(def.drag, time / TICK) : velocity; path.steps.push_back(point); point = next; } path.steps.push_back(point); - path.area = isEnded ? area : 0; + path.area = isEnded && !isBouncing ? area : 0; paths.push_back(path); } return paths; @@ -1977,7 +2219,7 @@ void Weapons::hook_attach(Sim& sim, Attack& attack) // An instant attack reaches its full range at once along its aim, stopping at the first wall (or, for a latching one, // the first target), where a latching one latches. Each Ricochet bounce marches its full range again as a segment of -// its own, its falloff starting afresh. A bursting one blows up at its tip, without a push. +// its own, its falloff starting afresh. A bursting one blows up at its tip, without a push, harder for each ricochet. void Weapons::instant_launch(Sim& sim, Attack& piece) { const AttackDef& def = *piece.def(); @@ -1988,33 +2230,32 @@ void Weapons::instant_launch(Sim& sim, Attack& piece) return true; return sim.world.is_blocking_at(point); }; - Vec2 start = def.kind == ATTACK_WHIP ? piece.origin : piece.position; + Vec2 start = ray_start_get(sim, def.kind == ATTACK_WHIP ? piece.origin : piece.position, piece.angle); piece.velocity = {}; piece.tip = ray_march(start, piece.angle, piece.stats.range, isBlocked); + // A latching one glances off walls for its Ricochet bounces before it latches where it ends. if (isLatching) { + while (piece.bounces > 0 && sim.world.is_blocking_at(piece.tip)) + { + piece.position = piece.tip - vec2_polar(piece.angle, RAY_STEP); + piece.angle = vec2_angle(reflect_get(sim, piece.position, piece.tip, vec2_polar(piece.angle, 1))); + piece.tip = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); + ricochet_count(sim, piece, piece.position); + } piece.position = piece.tip; if (sim.world.is_blocking_at(piece.position)) hook_attach(sim, piece); return; } sim.cues->burst_spawn(SPARK_BURST, piece.tip, piece.angle + PI, piece.owner); - Attack segment = piece; - for (int i = 0; i < piece.bounces; i++) - { - Vec2 before = segment.tip - vec2_polar(segment.angle, RAY_STEP); - segment.angle = vec2_angle(reflect_get(sim, before, segment.tip, vec2_polar(segment.angle, 1))); - segment.position = before; - segment.tip = ray_march(segment.position, segment.angle, piece.stats.range, isBlocked); - segment.ricochets++; - attacks.push_back(segment); - sim.cues->burst_spawn(SPARK_BURST, segment.tip, segment.angle + PI, piece.owner); - } + Attack segment = strands_spawn(sim, piece, attacks); if (def.isBursting) blast_spawn(sim, { - .position = piece.tip, + .position = segment.tip, .area = piece.stats.area * piece.scale / piece.stats.scale, - .damage = piece.stats.damage, + .bonus = piece.bonus * (1 + segment.ricochets * RICOCHET_BONUS), + .damage = piece.stats.damage * (1 + segment.ricochets * RICOCHET_BONUS), .owner = piece.owner, .sound = SOUND_NONE, // the attack's own sound already carries it .weapon = piece.weapon, @@ -2022,6 +2263,25 @@ void Weapons::instant_launch(Sim& sim, Attack& piece) }); } +// Glances a ray (piece, its tip where it ran into a wall) off walls for each of its Ricochet bounces, each bounce a +// strand of its own as long as its range, staying where it glanced, into the given attacks. Returns the last strand (or +// the piece, with none). +Attack Weapons::strands_spawn(Sim& sim, Attack& piece, std::vector& into) +{ + auto isBlocked = [&](Vec2 point) { + return sim.world.is_blocking_at(point); + }; + Attack segment = piece; + while (segment.bounces > 0 && isBlocked(segment.tip)) + { + strand_turn(sim, segment, piece.stats.range); + ricochet_count(sim, segment, segment.position); + into.push_back(segment); + sim.cues->burst_spawn(SPARK_BURST, segment.tip, segment.angle + PI, piece.owner); + } + return segment; +} + // Pulls a whip's wielder toward a wall its lash (reaching as far as it reaches when fired) meets short of full length. void Weapons::whip_pull(Sim& sim, Attack& piece) { @@ -2311,28 +2571,6 @@ void Weapons::vacuum_move(Sim& sim, Attack& attack) attack.isReturning = !isHeld; attack.length = std::clamp(attack.length + (isHeld ? TICK / VACUUM_SPIN_UP : -TICK / VACUUM_SPIN_DOWN), 0.0f, 1.0f); attack.isDead = !isHeld && attack.length <= 0; - for (int i = 0; i < sim.fighterCount; i++) - { - Fighter& target = sim.fighters[i]; - Vec2 center = target.center_get(); - if (!is_target(sim, i, attack.owner) || !is_in_cone(attack, center)) - continue; - // Boxing Glove pulls harder, as it knocks harder. - float boxing = fighter.stat_get(STAT_KNOCKBACK); - Vec2 toward = vec2_normalize(attack.position - center); - float room = std::max(0.0f, attack.stats.speed * boxing - vec2_dot(target.body.velocity, toward)); - float pull = std::min(room, VACUUM_PULL_RATE * boxing * TICK) * target.stat_get(STAT_KNOCKBACK_TAKEN); - target.body.velocity = target.body.velocity + toward * pull; - } - for (int i = 0; i < (int)sim.world.dummies.size(); i++) - { - Dummy& dummy = sim.world.dummies[i]; - Vec2 center = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h / 2}; - if (dummy.tile >= 0 || !is_target(sim, MAX_FIGHTERS + i, attack.owner) || !is_in_cone(attack, center)) - continue; - Vec2 toward = vec2_normalize(attack.position - center); - dummy.wobbleSpeed += toward.x * VACUUM_PULL_RATE * fighter.stat_get(STAT_KNOCKBACK) * WOBBLE_PER_PULL * TICK; - } // Each interval (the hit cooldown counting it down), everyone in the cone can be nibbled again. float interval = fighter.level_get(attack.key).cooldown / fighter.stat_get(STAT_ATTACK_RATE); bool isPulse = attack.hitCooldown <= 0; @@ -2341,19 +2579,49 @@ void Weapons::vacuum_move(Sim& sim, Attack& attack) attack.struck = 0; attack.hitCooldown = interval; } - int targets = std::min(MAX_FIGHTERS + (int)sim.world.dummies.size(), STRUCK_MAX); - for (int target = 0; target < targets; target++) - { - Rect box = target_box(sim, target); - Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; - if ((attack.struck >> target & 1) || !is_target(sim, target, attack.owner) || !is_in_cone(attack, center)) - continue; - attack.struck |= 1u << target; - attack_hit(sim, attack, target, center); - } // Only the first of a fan of copies (or repeats) lengthens lives, so they don't add up. bool isLengthening = isPulse && !attack.isCopy; - shots_drift(sim, attack, 0, VACUUM_PULL_RATE * TICK, isLengthening ? VACUUM_RESTORE : 0); + // Boxing Glove pulls harder, as it knocks harder. + float boxing = fighter.stat_get(STAT_KNOCKBACK); + int targets = std::min(MAX_FIGHTERS + (int)sim.world.dummies.size(), STRUCK_MAX); + // Bounced off walls by Ricochet, each cone pulls toward its own start, so whatever it catches goes around the bend. + Attack cones[CONES_MAX]; + int count = cones_glance(sim, attack, cones); + for (int c = 0; c < count; c++) + { + Attack& cone = cones[c]; + for (int i = 0; i < sim.fighterCount; i++) + { + Fighter& target = sim.fighters[i]; + Vec2 center = target.center_get(); + if (!is_target(sim, i, attack.owner) || !is_in_cone(cone, center)) + continue; + Vec2 toward = vec2_normalize(cone.position - center); + float room = std::max(0.0f, attack.stats.speed * boxing - vec2_dot(target.body.velocity, toward)); + float pull = std::min(room, VACUUM_PULL_RATE * boxing * TICK) * target.stat_get(STAT_KNOCKBACK_TAKEN); + target.body.velocity = target.body.velocity + toward * pull; + } + for (int i = 0; i < (int)sim.world.dummies.size(); i++) + { + Dummy& dummy = sim.world.dummies[i]; + Vec2 center = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h / 2}; + if (dummy.tile >= 0 || !is_target(sim, MAX_FIGHTERS + i, attack.owner) || !is_in_cone(cone, center)) + continue; + Vec2 toward = vec2_normalize(cone.position - center); + dummy.wobbleSpeed += toward.x * VACUUM_PULL_RATE * boxing * WOBBLE_PER_PULL * TICK; + } + for (int target = 0; target < targets; target++) + { + Rect box = target_box(sim, target); + Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; + if ((attack.struck >> target & 1) || !is_target(sim, target, attack.owner) || !is_in_cone(cone, center)) + continue; + attack.struck |= 1u << target; + attack_hit(sim, cone, target, center); + attack.hitCooldown = cone.hitCooldown; + } + shots_drift(sim, cone, 0, VACUUM_PULL_RATE * TICK, isLengthening ? VACUUM_RESTORE : 0); + } } // The Vacuum's airblast: once, as it goes off, its cone hits everyone in it for a little damage and knocks every @@ -2365,26 +2633,34 @@ void Weapons::gust_move(Sim& sim, Attack& attack) attack.isDead = attack.life_get() >= attack.lifetime; if (attack.time > 0) return; + // Bounced off walls by Ricochet, each cone blows out from its own start, hitting anyone once. int targets = std::min(MAX_FIGHTERS + (int)sim.world.dummies.size(), STRUCK_MAX); - for (int target = 0; target < targets; target++) + Attack cones[CONES_MAX]; + int count = cones_glance(sim, attack, cones); + for (int c = 0; c < count; c++) { - Rect box = target_box(sim, target); - Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; - if (!is_target(sim, target, attack.owner) || !is_in_cone(attack, center)) - continue; - attack_hit(sim, attack, target, center); - // Boxing Glove adds to it as to any knock. - Vec2 push = vec2_normalize(center - attack.position) * attack.stats.speed * cone_strength_get(attack, center) * - sim.fighters[attack.owner].stat_get(STAT_KNOCKBACK); - if (target >= MAX_FIGHTERS) + Attack& cone = cones[c]; + for (int target = 0; target < targets; target++) { - dummy_push(sim.world.dummies[target - MAX_FIGHTERS], push); - continue; + Rect box = target_box(sim, target); + Vec2 center = {box.x + box.w / 2, box.y + box.h / 2}; + if ((attack.struck >> target & 1) || !is_target(sim, target, attack.owner) || !is_in_cone(cone, center)) + continue; + attack.struck |= 1u << target; + attack_hit(sim, cone, target, center); + // Boxing Glove adds to it as to any knock. + Vec2 push = vec2_normalize(center - cone.position) * attack.stats.speed * cone_strength_get(cone, center) * + sim.fighters[attack.owner].stat_get(STAT_KNOCKBACK); + if (target >= MAX_FIGHTERS) + { + dummy_push(sim.world.dummies[target - MAX_FIGHTERS], push); + continue; + } + Fighter& fighter = sim.fighters[target]; + fighter.knock_take(push * fighter.stat_get(STAT_KNOCKBACK_TAKEN), attack.owner); } - Fighter& fighter = sim.fighters[target]; - fighter.knock_take(push * fighter.stat_get(STAT_KNOCKBACK_TAKEN), attack.owner); + shots_drift(sim, cone, attack.stats.speed * GUST_SHOT_SHARE, 0, attack.isCopy ? 0 : GUST_RESTORE); } - shots_drift(sim, attack, attack.stats.speed * GUST_SHOT_SHARE, 0, attack.isCopy ? 0 : GUST_RESTORE); } // A weapon (by its key, see Fighter::weapon_key) is busy once as many of it are out of the hand (wielded attacks) as diff --git a/src/weapon.h b/src/weapon.h index 9107c79..ab2278f 100644 --- a/src/weapon.h +++ b/src/weapon.h @@ -10,6 +10,8 @@ constexpr float RICOCHET_BONUS = 0.25f; // Hits for an attack that keeps hitting (a hit interval apart) as long as it's out. constexpr int HITS_ENDLESS = 1 << 30; constexpr int WHIP_RAYS = 9; +// The most cones a Vacuum's cone can glance into off walls, itself included. +constexpr int CONES_MAX = 8; // How long a thrown flail ball stays out before it reels itself back in. constexpr float FLAIL_LIFETIME = 15; @@ -71,6 +73,16 @@ enum AttackKind ATTACK_WISP }; +// Where an anchored attack is with Ricochet (see Weapons::anchor_bounce): following its anchor, loose (going again from +// the wall it glanced off as if the wall were its anchor), or heading home to its anchor from the last wall once done +// going out and back. +enum Glance +{ + GLANCE_NONE, + GLANCE_LOOSE, + GLANCE_HOME +}; + // What an attack moves with: nothing (flying free in the world), its fighter's hand, or its fighter's middle. enum AttackAnchor { @@ -131,6 +143,7 @@ constexpr float KNOCK_SLIGHT = 0.1f; // degrees either side, and scatter how far each piece of a volley may stray on top of that. Knock is the share of a // hit's knockback it delivers, and radius its hitbox's (grown with its scale). // - Anchor and path say how it moves (see AttackAnchor and AttackPath), and shape what it hits with (see AttackShape). +// An anchored one holds offset out along its aim from its anchor, past where its speed carries it. // - A flying shot's physics: accelerate (u/s², up to speedup times its speed), drag (the share of its speed it keeps // each tick), gravity (u/s², rising when negative), homing (how fast it turns toward its fighter's cursor, in // radians a second) and wall (see WallHit). One that bounces moves as a box body across, keeping bounce of its speed @@ -150,6 +163,8 @@ constexpr float KNOCK_SLIGHT = 0.1f; // isOrbiting: it circles its fighter in a shared orbit until launched (see Weapons::orbits_update). // isPassingPlatforms: it flies through platforms. // isProjectile: the Accelerator speeds it up. +// isRiding: it moves with its fighter, so its Ricochet bounces glance the fighter off what they run into instead (see +// Weapons::ride_bounce). // isRigid: its reach grows with its size as one rigid piece (a hammer, a Vacuum's cone). // isShot: it ends once out of hits. // isTethered: it's kept within its range of its fighter's hand (on a chain or rope). @@ -174,6 +189,7 @@ struct AttackDef float hold = 0; float homing = 0; float knock = 1; + float offset = 0; float radius = 0; float scale = 1; float shake = 0; @@ -195,6 +211,7 @@ struct AttackDef bool isOrbiting = false; bool isPassingPlatforms = false; bool isProjectile = false; + bool isRiding = false; bool isRigid = false; bool isShot = false; bool isTethered = false; @@ -380,14 +397,18 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .radius = 6, .shake = 0.025f, .spread = 18, + .isBouncy = true, .isProjectile = true}, .charged = {.label = "uppercut", .kind = ATTACK_UPPERCUT, .sound = SOUND_UPPERCUT, .anchor = ANCHOR_BODY, + .offset = 16, .radius = 14, .shake = 0.25f, - .spread = 5}, + .spread = 5, + .isBouncy = true, + .isRiding = true}, .levels = { {.normal = {.damage = 16, .lifetime = 0.12f, .speed = 283}, @@ -459,6 +480,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .radius = 8, .shake = 0.15f, .spread = 6, + .isBouncy = true, .isEased = true, .isProjectile = true, .isRigid = true, @@ -471,6 +493,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .radius = 8, .shake = 0.35f, .spread = 6, + .isBouncy = true, .isEased = true, .isProjectile = true, .isRigid = true, @@ -502,6 +525,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .radius = 3, .shake = 0.075f, .spread = 4, + .isBouncy = true, .isProjectile = true, .isWielded = true}, .charged = {.label = "lunging poke", @@ -512,6 +536,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .radius = 3, .shake = 0.2f, .spread = 3, + .isBouncy = true, .isProjectile = true, .isWielded = true}, .levels = @@ -544,6 +569,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .radius = 4, .shake = 0.05f, .spread = 7.5f, + .isBouncy = true, .isProjectile = true}, .charged = {.label = "lash + blast", .kind = ATTACK_WHIP, @@ -554,6 +580,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .shake = 0.15f, .spread = 0.5f, .hits = 0, + .isBouncy = true, .isBursting = true, .isInstant = true}, .levels = @@ -585,6 +612,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .shake = 0.075f, .spread = 8, .hits = HITS_ENDLESS, + .isBouncy = true, .isProjectile = true, .isWielded = true}, .charged = {.label = "ball throw", @@ -687,6 +715,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .shake = 0.1f, .spread = 2, .isAligned = true, + .isBouncy = true, .isDrifting = true, .isFalloff = true, .isInheriting = true, @@ -702,6 +731,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .shake = 0.2f, .spread = 1, .isAligned = true, + .isBouncy = true, .isDrifting = true, .isFalloff = true, .isInstant = true, @@ -969,6 +999,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .spread = 4, .hits = HITS_ENDLESS, .isAligned = true, + .isBouncy = true, .isDrifting = true, .isFalloff = true, .isInheriting = true, @@ -985,6 +1016,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .spread = 2, .hits = HITS_ENDLESS, .isAligned = true, + .isBouncy = true, .isDrifting = true, .isFalloff = true, .isInheriting = true, @@ -1222,6 +1254,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .shape = SHAPE_CONE, .knock = 0, .hits = 0, + .isBouncy = true, .isRigid = true}, .charged = {.label = "airblast", .kind = ATTACK_GUST, @@ -1232,6 +1265,7 @@ inline constexpr WeaponDef WEAPON_DEFS[WEAPON_COUNT] = { .knock = 0, .shake = 0.15f, .hits = 0, + .isBouncy = true, .isClaiming = true, .isRigid = true}, .levels = @@ -1393,6 +1427,7 @@ struct Attack int hitsLeft = 0; int owner = 0; int ownerPrevious = -1; + // How many times it's ricocheted (for a cone, how many walls it glanced off last tick, see cones_get). int ricochets = 0; // Targets already hit, a bit each: by a grenade's clonks, or by a Vacuum's nibble this interval. Uint32 struck = 0; @@ -1404,6 +1439,7 @@ struct Attack // The hand it came from: the main hand (0) or a Dual Wield off-hand (1 up). int hand = 0; AttackVariant variant = VARIANT_NORMAL; + Glance glance = GLANCE_NONE; bool isAttached = false; bool isCopy = false; bool isDead = false; @@ -1461,6 +1497,7 @@ float fill_get(float strength); std::vector lash_get(Attack& attack); Vec2 muzzle_get(Attack& attack); int shapes_get(Sim& sim, Attack& attack, Capsule* shapes); +int cones_get(Sim& sim, Attack& attack, Attack* cones); // One hit's worth of damage and the feedback that goes with it. Source is where it came from, which a fighter gets // knocked away from, and knock scales how hard. Targets are numbered fighters first, then dummies @@ -1583,6 +1620,8 @@ class Weapons public: std::vector attacks; + // Explosions queued to hit next tick (see blast_spawn). + std::vector pending; float orbitClock = 0; void fire(Sim& sim, Fighter& fighter, float charge); @@ -1601,14 +1640,14 @@ public: void contact_update(Sim& sim, Fighter& fighter); void orbits_release(Fighter& fighter); void teleport_move(Sim& sim, Attack& attack); + bool ride_bounce(Sim& sim, Fighter& fighter, Vec2 before); void solids_add(Fighter& fighter, World& world); private: - std::vector pending; - bool is_spawned(Sim& sim, Fighter& fighter, Shot shot); void attack_move(Sim& sim, Attack& attack); void path_move(Sim& sim, Attack& attack); + void anchor_bounce(Sim& sim, Attack& attack, Vec2 last); void flight_move(Sim& sim, Attack& attack); void wall_hit(Sim& sim, Attack& attack, Vec2 wall, bool isHeld); void attack_remember(Attack& attack); @@ -1618,6 +1657,7 @@ private: Attack* tether_get(Fighter& fighter, int key, int hand); void hook_attach(Sim& sim, Attack& attack); void instant_launch(Sim& sim, Attack& piece); + Attack strands_spawn(Sim& sim, Attack& piece, std::vector& into); void whip_pull(Sim& sim, Attack& piece); void attack_explode(Sim& sim, Attack& attack); void vacuum_move(Sim& sim, Attack& attack);