diff --git a/CMakeLists.txt b/CMakeLists.txt index a8487ad..01686e3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -37,6 +37,8 @@ add_library(simdjson STATIC external/simdjson/singleheader/simdjson.cpp) target_include_directories(simdjson SYSTEM PUBLIC external/simdjson/singleheader) target_compile_definitions(simdjson PUBLIC SIMDJSON_EXCEPTIONS=0) +include(src/sim.cmake) + # Networking: WebRTC data channels between players and a WebSocket to the server. On desktop it's # libdatachannel (static, data channels only, no media); in the browser, datachannel-wasm gives the same API over the # browser's own WebRTC. @@ -54,9 +56,10 @@ else() endif() file(GLOB SOURCES CONFIGURE_DEPENDS src/*.cpp) +list(REMOVE_ITEM SOURCES ${SIM_SOURCES}) add_executable(smack_a_brother ${SOURCES}) target_include_directories(smack_a_brother SYSTEM PRIVATE external/stb) -target_link_libraries(smack_a_brother PRIVATE SDL3::SDL3-static simdjson ${NETWORK_LIBRARY}) +target_link_libraries(smack_a_brother PRIVATE sim SDL3::SDL3-static simdjson ${NETWORK_LIBRARY}) set_target_properties(smack_a_brother PROPERTIES OUTPUT_NAME smack-a-brother) file(CREATE_LINK ${CMAKE_SOURCE_DIR}/resources ${CMAKE_BINARY_DIR}/resources SYMBOLIC) diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index e4e1956..b1629c8 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -1,4 +1,5 @@ -# The server. Built as part of the game's desktop build, or on its own (for the VPS, without SDL): +# The server, which runs games with the simulation library. Built as part of the game's desktop build, or on its own +# (for the VPS, without SDL): # cmake -S server -B build-server -DCMAKE_BUILD_TYPE=Release && cmake --build build-server cmake_minimum_required(VERSION 3.24) project(smack_a_brother_server LANGUAGES C CXX) @@ -13,6 +14,9 @@ if(NOT TARGET datachannel-static) set(NO_TESTS ON CACHE BOOL "" FORCE) add_subdirectory(${EXTERNAL}/libdatachannel ${CMAKE_BINARY_DIR}/libdatachannel EXCLUDE_FROM_ALL) endif() +if(NOT TARGET sim) + include(${CMAKE_CURRENT_SOURCE_DIR}/../src/sim.cmake) +endif() if(NOT TARGET simdjson) add_library(simdjson STATIC ${EXTERNAL}/simdjson/singleheader/simdjson.cpp) target_include_directories(simdjson SYSTEM PUBLIC ${EXTERNAL}/simdjson/singleheader) @@ -21,5 +25,5 @@ endif() add_executable(smack_a_brother_server server.cpp) target_include_directories(smack_a_brother_server PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../src) -target_link_libraries(smack_a_brother_server PRIVATE LibDataChannel::LibDataChannelStatic simdjson) +target_link_libraries(smack_a_brother_server PRIVATE sim LibDataChannel::LibDataChannelStatic simdjson) set_target_properties(smack_a_brother_server PROPERTIES OUTPUT_NAME smack-a-brother-server) diff --git a/src/audio.cpp b/src/audio.cpp index 0a16a90..ce207ea 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -1,5 +1,5 @@ #include "audio.h" -#include "common.h" +#include "renderer.h" #include #include @@ -132,7 +132,7 @@ int Audio::play(SoundId id) return -1; Sound& sound = sounds[id]; Uint64 now = SDL_GetTicksNS(); - if (!device || sound.frames <= 0 || isMuted || now - sound.played < SOUND_GAP) + if (!device || sound.frames <= 0 || now - sound.played < SOUND_GAP) return -1; sound.played = now; diff --git a/src/audio.h b/src/audio.h index 960d55f..227ae29 100644 --- a/src/audio.h +++ b/src/audio.h @@ -113,8 +113,6 @@ struct Sound class Audio { public: - bool isMuted = false; - void init(); int play(SoundId id); void stop(int voice); diff --git a/src/balance.cpp b/src/balance.cpp index 633fd32..37b8d48 100644 --- a/src/balance.cpp +++ b/src/balance.cpp @@ -44,7 +44,7 @@ static void tallies_print(const char* title, Tally* tallies, int count) // (The bytes themselves can differ between builds, in the padding between fields, which nothing reads.) static void state_test(Game& game, bool isFirst) { - std::string bytes = state_save(game); + std::string bytes = state_save(game.sim); const char* imported = SDL_getenv("RPG_STATE_IMPORT_HEX"); if (isFirst && SDL_getenv("RPG_STATE_PRINT")) { @@ -67,13 +67,13 @@ static void state_test(Game& game, bool isFirst) bytes += (char)(value(digit[0]) << 4 | value(digit[1])); } } - for (Fighter& fighter : game.fighters) + for (Fighter& fighter : game.sim.fighters) fighter = Fighter{}; - game.weapons.attacks.clear(); - game.world.dummies.clear(); - if (!state_load(game, bytes)) + game.sim.weapons.attacks.clear(); + game.sim.world.dummies.clear(); + if (!state_load(game.sim, bytes)) SDL_Log("State test: the saved state didn't load"); - else if (!imported && state_save(game) != bytes) + else if (!imported && state_save(game.sim) != bytes) SDL_Log("State test: the state saved after loading differs"); } @@ -86,9 +86,7 @@ void Balance::matches_run(Game& game, int count, MatchMode mode) for (int i = 0; i < TRINKET_COUNT; i++) trinkets[i].name = TRINKET_DEFS[i].name; - game.audio.isMuted = true; - game.effects.isMuted = true; - for (Fighter& fighter : game.fighters) + for (Fighter& fighter : game.sim.fighters) fighter.isBot = true; int limit = MATCH_TIME_LIMIT / TICK; int timeouts = 0; @@ -104,38 +102,38 @@ void Balance::matches_run(Game& game, int count, MatchMode mode) for (int match = 0; match < count; match++) { // Items dealt at random and kept at their start level, so a weapon's win rate measures the weapon. - game.match.rules = rules_get(mode); - for (ItemRules* items : {&game.match.rules.weapons, &game.match.rules.trinkets}) + game.sim.match.rules = rules_get(mode); + for (ItemRules* items : {&game.sim.match.rules.weapons, &game.sim.match.rules.trinkets}) { items->isChoice = false; items->isLevelUp = false; } - game.match.start(game); + game.sim.match.start(game.sim); int tick = 0; - for (; game.match.state != MATCH_OVER && tick < limit; tick++) + for (; game.sim.match.state != MATCH_OVER && tick < limit; tick++) { game_tick(game); - hash = (hash ^ game.rollback.checksum_get(game)) * HASH_PRIME; + hash = (hash ^ game.sim.checksum_get()) * HASH_PRIME; if (tick == STATE_TEST_TICK && isStateTest) state_test(game, match == 0); } ticks += tick; if (count >= PROGRESS_STEPS && (match + 1) % (count / PROGRESS_STEPS) == 0) SDL_Log("%d / %d matches", match + 1, count); - bool isStalled = game.match.state != MATCH_OVER; + bool isStalled = game.sim.match.state != MATCH_OVER; timeouts += isStalled; - racers += game.fighterCount; - for (int i = 0; i < game.fighterCount; i++) - finishes += game.match.finishTimes[i] >= 0; - if (game.match.winner >= 0) + racers += game.sim.fighterCount; + for (int i = 0; i < game.sim.fighterCount; i++) + finishes += game.sim.match.finishTimes[i] >= 0; + if (game.sim.match.winner >= 0) { wins++; - winTimes += game.match.finishTimes[game.match.winner]; + winTimes += game.sim.match.finishTimes[game.sim.match.winner]; } - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; - bool isWinner = i == game.match.winner; + Fighter& fighter = game.sim.fighters[i]; + bool isWinner = i == game.sim.match.winner; for (int weapon : fighter.weaponLoadout) { if (weapon < 0) diff --git a/src/bot.cpp b/src/bot.cpp index 9403637..f6e3683 100644 --- a/src/bot.cpp +++ b/src/bot.cpp @@ -1,7 +1,7 @@ #include "bot.h" -#include "game.h" -#include +#include "sim.h" #include +#include constexpr int PURSUIT_TICKS = 60; constexpr int SURFACE_CLEARANCE = 2; @@ -28,11 +28,9 @@ constexpr float GRAPPLE_COST = 20; constexpr float GRAPPLE_GRIP = 4; constexpr float GRAPPLE_RISE_MAX = 200; constexpr float GRAPPLE_TIME = 2.5f; -constexpr float GOAL_SPAN = 0.5f; constexpr float JUMP_COST = 40; constexpr float JUMP_REACH = 0.8f; constexpr float JUMP_RISE_MAX = 64; -constexpr float LINK_WIDTH = 1; constexpr float RAY_STEP = 4; constexpr float RECOVER_AIM = 48; constexpr float RECOVER_TIME = 0.25f; @@ -41,7 +39,6 @@ constexpr float STRAFE_DISTANCE = 40; constexpr float STUCK_SPEED = 5; constexpr float STUCK_TIME = 0.5f; constexpr float SEAM_TOLERANCE = 2; -constexpr float SURFACE_WIDTH = 2; constexpr float TAKEOFF_INSET = TILE_SIZE; constexpr float THREAT_SPEED = 50; constexpr float WHIP_SWEET_SPOT = 8; @@ -70,12 +67,12 @@ static int grapple_slot_get(Fighter& fighter) return -1; } -static bool is_sight_clear(Game& game, Vec2 from, Vec2 to) +static bool is_sight_clear(Sim& sim, Vec2 from, Vec2 to) { Vec2 delta = to - from; int steps = vec2_length(delta) / RAY_STEP; for (int i = 1; i < steps; i++) - if (game.world.is_blocking_at(from + delta * ((float)i / steps))) + if (sim.world.is_blocking_at(from + delta * ((float)i / steps))) return false; return true; } @@ -123,14 +120,14 @@ static void seen_add(BotState& state, Vec2 position) // Where a launched fighter will come to rest: its flight played forward with the real launch physics, bouncing off // whatever it meets, until it slows down or the look-ahead runs out. -static Vec2 landing_predict(Game& game, Fighter& fighter) +static Vec2 landing_predict(Sim& sim, Fighter& fighter) { Body body = fighter.body; for (int i = 0; i < PURSUIT_TICKS && vec2_length(body.velocity) >= LAUNCH_END_SPEED; i++) { Vec2 before = body.velocity * (1 - KNOCK_DECAY * TICK) + Vec2{0, GRAVITY * TICK}; body.velocity = before; - game.world.body_move(body); + sim.world.body_move(body); if (body.velocity.x == 0 && std::abs(before.x) > BOUNCE_MIN) body.velocity.x = -before.x * BOUNCE_KEEP; if (body.velocity.y == 0 && std::abs(before.y) > BOUNCE_MIN) @@ -141,11 +138,11 @@ static Vec2 landing_predict(Game& game, Fighter& fighter) // Launched toward a slam, grab whatever softens it and use it toward the wall: a hammer swing to rebound off it, // flames to fly into and brake, or a shot whose recoil pushes back. Reports whether it's doing so. -static bool is_recovering(Game& game, Fighter& fighter, Controls& controls) +static bool is_recovering(Sim& sim, Fighter& fighter, Controls& controls) { Vec2 velocity = fighter.body.velocity; Vec2 center = fighter.center_get(); - bool isHeading = !is_sight_clear(game, center, center + velocity * RECOVER_TIME); + bool isHeading = !is_sight_clear(sim, center, center + velocity * RECOVER_TIME); if (!fighter.isLaunched || vec2_length(velocity) < SLAM_SPEED || !isHeading) return false; @@ -175,10 +172,10 @@ static bool is_recovering(Game& game, Fighter& fighter, Controls& controls) } // Whether an enemy attack in flight is on course to pass through the fighter soon. -static bool is_threatened(Game& game, Fighter& fighter) +static bool is_threatened(Sim& sim, Fighter& fighter) { Vec2 center = fighter.center_get(); - for (Attack& attack : game.weapons.attacks) + for (Attack& attack : sim.weapons.attacks) { float speed = vec2_length(attack.velocity); if (attack.owner == fighter.index || attack.isDead || attack.isAttached || speed < THREAT_SPEED) @@ -191,13 +188,13 @@ static bool is_threatened(Game& game, Fighter& fighter) return false; } -void Bot::init(Game& game) +void Bot::init(World& world) { - isDrawn = SDL_getenv("RPG_BOT_DEBUG"); - const char* chosen = SDL_getenv("RPG_BOT_DIFFICULTY"); + isDrawn = std::getenv("RPG_BOT_DEBUG"); + const char* chosen = std::getenv("RPG_BOT_DIFFICULTY"); if (chosen) - level = std::clamp(SDL_atoi(chosen), 0, BOT_LEVEL_COUNT - 1); - graph_build(game.world); + level = std::clamp(std::atoi(chosen), 0, BOT_LEVEL_COUNT - 1); + graph_build(world); } void Bot::graph_build(World& world) @@ -410,37 +407,37 @@ bool Bot::is_path_found(BotState& state, int from, int to, bool isGrappler) } // Writes every living bot's controls for this tick, re-thinking on its interval. -void Bot::update(Game& game) +void Bot::update(Sim& sim) { - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; BotState& state = states[i]; if (!fighter.isBot || !fighter.isAlive) continue; - bool isRacing = game.match.is_racing(); + bool isRacing = sim.match.is_racing(); state.thinkTimer -= TICK; if (state.thinkTimer <= 0 && isRacing) race_think(fighter, state); else if (state.thinkTimer <= 0) - think(game, fighter, state); + think(sim, fighter, state); if (state.target >= 0) - seen_add(state, game.fighters[state.target].center_get()); + seen_add(state, sim.fighters[state.target].center_get()); Controls controls = {.cursor = fighter.controls.cursor}; - if (is_recovering(game, fighter, controls)) + if (is_recovering(sim, fighter, controls)) { fighter.controls = controls; continue; } - move_steer(game, fighter, state, controls); + move_steer(sim, fighter, state, controls); if (isRacing && !state.isGrappling) - block_attack(game, fighter, state, controls); + block_attack(sim, fighter, state, controls); else if (!state.isGrappling) - attack_aim(game, fighter, state, controls); + attack_aim(sim, fighter, state, controls); wings_steer(fighter, state, controls); if (!state.isGrappling && !isRacing) - trinket_steer(game, fighter, state, controls); + trinket_steer(sim, fighter, state, controls); fighter.controls = controls; } } @@ -449,10 +446,10 @@ void Bot::update(Game& game) // strafe and alertness, and where to stand: out of reach of an enemy holding a full charge (unless cornered), // drifting about when in // band with a clear shot, otherwise at band distance from the target on the surface under it. -void Bot::think(Game& game, Fighter& fighter, BotState& state) +void Bot::think(Sim& sim, Fighter& fighter, BotState& state) { const BotDifficulty& skill = BOT_DIFFICULTIES[level]; - Rng& rng = game.rng; + Rng& rng = sim.rng; int previous = state.target; state.thinkTimer = skill.thinkInterval; state.aimError = rng.random_get(-skill.aimError, skill.aimError); @@ -460,11 +457,11 @@ void Bot::think(Game& game, Fighter& fighter, BotState& state) state.strafe = (int)rng.random_get(0, 3) - 1; state.target = -1; Vec2 center = fighter.center_get(); - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& other = game.fighters[i]; + Fighter& other = sim.fighters[i]; bool isCloser = state.target < 0 || vec2_length(other.center_get() - center) < - vec2_length(game.fighters[state.target].center_get() - center); + vec2_length(sim.fighters[state.target].center_get() - center); if (i != fighter.index && other.isAlive && isCloser) state.target = i; } @@ -473,9 +470,9 @@ void Bot::think(Game& game, Fighter& fighter, BotState& state) if (state.target != previous) state.seenCount = 0; if (state.seenCount == 0) - seen_add(state, game.fighters[state.target].center_get()); + seen_add(state, sim.fighters[state.target].center_get()); - Fighter& target = game.fighters[state.target]; + Fighter& target = sim.fighters[state.target]; Vec2 seen = seen_get(state, skill.reactionTicks); float distance = vec2_length(seen - fighter.hand_get()); float bestMiss = INFINITY; @@ -518,7 +515,7 @@ void Bot::think(Game& game, Fighter& fighter, BotState& state) } bool isInBand = distance >= hint.rangeMin && distance <= hint.rangeMax; - if (isInBand && is_sight_clear(game, fighter.hand_get(), seen)) + if (isInBand && is_sight_clear(sim, fighter.hand_get(), seen)) { state.goal = center; bool isNarrow = hint.rangeMax - hint.rangeMin < STRAFE_DISTANCE * 2; @@ -530,7 +527,7 @@ void Bot::think(Game& game, Fighter& fighter, BotState& state) } // A launched target is chased to where it will come down. - Vec2 chase = target.isLaunched ? landing_predict(game, target) : seen; + Vec2 chase = target.isLaunched ? landing_predict(sim, target) : seen; float standoff = (hint.rangeMin + hint.rangeMax) * GOAL_SPAN; int goalSurface = surface_below(chase, -BOX_HEIGHT); if (goalSurface < 0) @@ -559,14 +556,14 @@ void Bot::race_think(Fighter& fighter, BotState& state) // Follows the path: walk to each link's takeoff and act there (jump, drop through or walk off), steer for the // landing while airborne, and walk to the goal once on its surface. A bot that stops moving short of where it's // going jumps. -void Bot::move_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls) +void Bot::move_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& controls) { Body& body = fighter.body; float x = fighter.center_get().x; int current = surface_find(fighter); if (state.isGrappling) { - grapple_steer(game, fighter, state, controls); + grapple_steer(sim, fighter, state, controls); return; } @@ -635,7 +632,7 @@ void Bot::move_steer(Game& game, Fighter& fighter, BotState& state, Controls& co } if (isCarried) state.landing = link->landing; - if (isStuck || (state.isAlert && is_threatened(game, fighter))) + if (isStuck || (state.isAlert && is_threatened(sim, fighter))) { state.landing = aim; state.isJumping = true; @@ -646,11 +643,11 @@ void Bot::move_steer(Game& game, Fighter& fighter, BotState& state, Controls& co // Grapple climb: switch to the grapple, charge it, then fire the instant grapple at the platform above and ride the // pull. Let go once the feet clear the platform (or the pull ends), then steer onto it like any landing. Gives up // after a while. -void Bot::grapple_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls) +void Bot::grapple_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& controls) { state.grappleTimer += TICK; int slot = grapple_slot_get(fighter); - Attack* hook = game.weapons.hook_get(fighter); + Attack* hook = sim.weapons.hook_get(fighter); bool isAbove = fighter.body.box.y + fighter.body.box.h < state.landingY - FEET_TOLERANCE; controls.cursor = {state.landing, state.landingY + GRAPPLE_GRIP}; @@ -694,12 +691,12 @@ void Bot::wings_steer(Fighter& fighter, BotState& state, Controls& controls) // or wall jump by accident. // - Wings, besides saving short jumps: dash in to close the last stretch with a short-range weapon, or out of the way // of an attack about to hit while alert. -void Bot::trinket_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls) +void Bot::trinket_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& controls) { Body& body = fighter.body; if (body.isGrounded || fighter.isLaunched || state.target < 0) return; - Fighter& target = game.fighters[state.target]; + Fighter& target = sim.fighters[state.target]; Vec2 center = fighter.center_get(); Vec2 targetCenter = target.center_get(); float feet = body.box.y + body.box.h; @@ -708,7 +705,7 @@ void Bot::trinket_steer(Game& game, Fighter& fighter, BotState& state, Controls& bool isDashReady = fighter.is_trinket_equipped(TRINKET_WINGS) && fighter.trinket_cooldown_get(TRINKET_WINGS) <= 0; float targetFeet = target.body.box.y + target.body.box.h; - bool isOnSolid = game.world.is_solid_at({targetCenter.x, targetFeet + 1}); + bool isOnSolid = sim.world.is_solid_at({targetCenter.x, targetFeet + 1}); bool isPounding = fighter.is_trinket_equipped(TRINKET_WEIGHTS) && isFalling && target.body.isGrounded && isOnSolid && targetFeet > feet + TILE_SIZE && std::abs(targetCenter.x - center.x) < POUND_REACH; if (isPounding) @@ -718,7 +715,7 @@ void Bot::trinket_steer(Game& game, Fighter& fighter, BotState& state, Controls& } int climb = fighter.trinket_get(TRINKET_CLIMBING, 0); - int wall = climb > 0 ? fighter.wall_get(game) : 0; + int wall = climb > 0 ? fighter.wall_get(sim) : 0; bool isWantingUp = state.landingY < feet - CLIMB_RISE || targetCenter.y < center.y - 2 * TILE_SIZE; if (wall != 0 && isWantingUp) { @@ -748,7 +745,7 @@ void Bot::trinket_steer(Game& game, Fighter& fighter, BotState& state, Controls& BotHint hint = weapon >= 0 ? hint_get(fighter, fighter.slot, state.isChargePlanned) : BotHint{}; bool isChasing = weapon >= 0 && hint.rangeMax <= DASH_SHORT_RANGE && distance > hint.rangeMax && distance < hint.rangeMax + DASH_CHASE; - bool isDodging = state.isAlert && is_threatened(game, fighter); + bool isDodging = state.isAlert && is_threatened(sim, fighter); if (!isChasing && !isDodging) return; float side = targetCenter.x < center.x ? 1 : -1; @@ -805,12 +802,12 @@ static Vec2 aim_point_get(Fighter& fighter, int weaponSlot, bool isCharged, Vec2 // switches to the planned slot, holds attack in band once aimed, and charges while close enough to need it soon, // firing once full, aimed and in band (or once it has waited too long). Mines detonate when the target stands inside // one's blast. -void Bot::attack_aim(Game& game, Fighter& fighter, BotState& state, Controls& controls) +void Bot::attack_aim(Sim& sim, Fighter& fighter, BotState& state, Controls& controls) { if (state.target < 0) return; const BotDifficulty& skill = BOT_DIFFICULTIES[level]; - Fighter& target = game.fighters[state.target]; + Fighter& target = sim.fighters[state.target]; Vec2 seen = seen_get(state, skill.reactionTicks); Vec2 velocity = (seen - seen_get(state, skill.reactionTicks + 1)) * (1 / TICK); Vec2 hand = fighter.hand_get(); @@ -826,7 +823,7 @@ void Bot::attack_aim(Game& game, Fighter& fighter, BotState& state, Controls& co return; // A hook that struck lets go at once, or once a charged one has yanked the bot in to its target. - Attack* hook = game.weapons.hook_get(fighter); + Attack* hook = sim.weapons.hook_get(fighter); if (hook) { if (!hook->isPulling) @@ -839,18 +836,18 @@ void Bot::attack_aim(Game& game, Fighter& fighter, BotState& state, Controls& co bool isAimed = std::abs(turn) < AIM_READY; bool isNarrow = hint.rangeMax - hint.rangeMin < STRAFE_DISTANCE * 2; bool isInBand = - distance <= hint.rangeMax && (distance >= hint.rangeMin || !isNarrow) && is_sight_clear(game, hand, seen); + distance <= hint.rangeMax && (distance >= hint.rangeMin || !isNarrow) && is_sight_clear(sim, hand, seen); state.chargeHeld = fighter.charge >= 1 ? state.chargeHeld + TICK : 0; // Thrown weapons come back once they're past the target or have hung out a while, and mines go off when the // target is inside one's blast. - for (Attack& attack : game.weapons.attacks) + for (Attack& attack : sim.weapons.attacks) { if (attack.owner != fighter.index || attack.isDead || attack.isReturning) continue; AttackKind kind = attack.def()->kind; bool isPast = vec2_length(attack.position - hand) > distance + BOOMERANG_OVERSHOOT; - if (kind == ATTACK_BOOMERANG && isPast && game.weapons.is_busy(fighter, fighter.weapon_key(fighter.slot))) + if (kind == ATTACK_BOOMERANG && isPast && sim.weapons.is_busy(fighter, fighter.weapon_key(fighter.slot))) { control_tap(fighter, controls, CONTROL_CHARGE); return; @@ -889,9 +886,9 @@ void Bot::attack_aim(Game& game, Fighter& fighter, BotState& state, Controls& co // Racing: hacks at the nearest unbroken block in its body's way, just ahead in the way it's walking, holding attack // with a weapon that strikes on its own (not a mine detonator, and a grapple only as a last resort). Holding off the // stuck-jump while it chops. -void Bot::block_attack(Game& game, Fighter& fighter, BotState& state, Controls& controls) +void Bot::block_attack(Sim& sim, Fighter& fighter, BotState& state, Controls& controls) { - World& world = game.world; + World& world = sim.world; Rect box = fighter.body.box; bool isLeft = controls.buttons >> CONTROL_LEFT & 1; Rect ahead = {isLeft ? box.x - BLOCK_SEEK : box.x, box.y, box.w + BLOCK_SEEK, box.h}; @@ -926,7 +923,7 @@ void Bot::block_attack(Game& game, Fighter& fighter, BotState& state, Controls& if (fighter.slot != best) controls.slot = best; - Attack* hook = game.weapons.hook_get(fighter); + Attack* hook = sim.weapons.hook_get(fighter); if (hook) { control_tap(fighter, controls, CONTROL_ATTACK); @@ -935,32 +932,3 @@ void Bot::block_attack(Game& game, Fighter& fighter, BotState& state, Controls& controls.cursor = target; control_set(controls, CONTROL_ATTACK, fighter.slot == best); } - -// Debug view: surfaces as lines, links as thin lines from takeoff to landing, and each bot's remaining path in white. -void Bot::draw(Game& game) -{ - if (!isDrawn) - return; - - Renderer& renderer = game.renderer; - for (Surface& surface : surfaces) - renderer.line_draw({surface.left, surface.y_at(surface.left)}, {surface.right, surface.y_at(surface.right)}, - SURFACE_WIDTH, GRAY); - for (Link& link : links) - renderer.line_draw({link.takeoff, surfaces[link.from].y_at(link.takeoff)}, - {link.landing, surfaces[link.to].y_at(link.landing)}, LINK_WIDTH, - color_fade(GRAY, GOAL_SPAN)); - for (int i = 0; i < game.fighterCount; i++) - { - BotState& state = states[i]; - if (!game.fighters[i].isBot) - continue; - for (int step = state.pathStep; step < state.pathLength; step++) - { - Link& link = links[state.path[step]]; - renderer.line_draw({link.takeoff, surfaces[link.from].y_at(link.takeoff)}, - {link.landing, surfaces[link.to].y_at(link.landing)}, SURFACE_WIDTH, WHITE); - } - renderer.circle_draw(state.goal, SURFACE_WIDTH * 2, WHITE); - } -} diff --git a/src/bot.h b/src/bot.h index 9871314..0371777 100644 --- a/src/bot.h +++ b/src/bot.h @@ -2,7 +2,9 @@ #include "fighter.h" -struct Game; +constexpr float GOAL_SPAN = 0.5f; + +struct Sim; constexpr int BOT_HISTORY = 20; constexpr int BOT_PATH_MAX = 16; @@ -116,14 +118,15 @@ struct BotState // each bot's path. Level is the difficulty every bot plays at, from RPG_BOT_DIFFICULTY (0 to 2). class Bot { + friend class Scene; + public: BotState states[MAX_FIGHTERS]; int level = BOT_NORMAL; - void init(Game& game); + void init(World& world); void graph_build(World& world); - void update(Game& game); - void draw(Game& game); + void update(Sim& sim); private: std::vector links; @@ -136,12 +139,12 @@ private: int surface_find(Fighter& fighter); int surface_below(Vec2 point, float above); bool is_path_found(BotState& state, int from, int to, bool isGrappler); - void think(Game& game, Fighter& fighter, BotState& state); + void think(Sim& sim, Fighter& fighter, BotState& state); void race_think(Fighter& fighter, BotState& state); - void move_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls); - void grapple_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls); + void move_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& controls); + void grapple_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& controls); void wings_steer(Fighter& fighter, BotState& state, Controls& controls); - void trinket_steer(Game& game, Fighter& fighter, BotState& state, Controls& controls); - void attack_aim(Game& game, Fighter& fighter, BotState& state, Controls& controls); - void block_attack(Game& game, Fighter& fighter, BotState& state, Controls& controls); + void trinket_steer(Sim& sim, Fighter& fighter, BotState& state, Controls& controls); + void attack_aim(Sim& sim, Fighter& fighter, BotState& state, Controls& controls); + void block_attack(Sim& sim, Fighter& fighter, BotState& state, Controls& controls); }; diff --git a/src/chat.cpp b/src/chat.cpp index 3b86b2f..d356d06 100644 --- a/src/chat.cpp +++ b/src/chat.cpp @@ -92,7 +92,7 @@ void Chat::players_clear() int Chat::member_get(Game& game, int fighter) { if (game.session.isActive) - return game.match.owners[fighter]; + return game.sim.match.owners[fighter]; return fighter == game.local ? game.net.selfId : NO_MEMBER; } @@ -267,8 +267,8 @@ void Chat::command(Game& game, const std::string& text) if (SDL_strcasecmp(argument.c_str(), COLOR_NAMES[color]) != 0) continue; int own = game.session.fighter_get(game, game.net.selfId); - for (int i = 0; i < game.fighterCount && game.session.isActive; i++) - if (i != own && game.match.colors[i] == color) + for (int i = 0; i < game.sim.fighterCount && game.session.isActive; i++) + if (i != own && game.sim.match.colors[i] == color) return system(std::string(COLOR_NAMES[color]) + " is already taken by another player."); game.settings.color = color; game.settings.changed(); @@ -284,7 +284,7 @@ void Chat::command(Game& game, const std::string& text) Rect Chat::log_area_get(Game& game) { float bottom = CANVAS_HEIGHT - HOTBAR_MARGIN; - MatchState state = (MatchState)game.match.state; + MatchState state = (MatchState)game.sim.match.state; if (state != MATCH_PRACTICE && state != MATCH_COUNTDOWN) { RivalLayout rivals = game.ui.rivals_layout(game); @@ -343,10 +343,10 @@ void Chat::draw(Game& game) void Chat::overhead_draw(Game& game) { float now = seconds_get(); - float above = game.match.state == MATCH_PRACTICE ? BUBBLE_ABOVE : BUBBLE_MATCH_ABOVE; - for (int i = 0; i < game.fighterCount; i++) + float above = game.sim.match.state == MATCH_PRACTICE ? BUBBLE_ABOVE : BUBBLE_MATCH_ABOVE; + for (int i = 0; i < game.sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = game.sim.fighters[i]; int member = member_get(game, i); if (!fighter.isAlive || member == NO_MEMBER) continue; diff --git a/src/common.h b/src/common.h index 0a4ee9c..765a12b 100644 --- a/src/common.h +++ b/src/common.h @@ -1,11 +1,15 @@ #pragma once #include "dmath.h" -#include -#include #include +#include #include +using Uint8 = std::uint8_t; +using Uint16 = std::uint16_t; +using Uint32 = std::uint32_t; +using Uint64 = std::uint64_t; + constexpr const char* GAME_ID = "smack-a-brother"; constexpr const char* GAME_NAME = "Smack a Brother"; constexpr int CANVAS_HEIGHT = 360; @@ -124,18 +128,26 @@ inline float angle_lerp(float from, float to, float t) return from + std::remainder(to - from, 2 * PI) * t; } -// Cosmetic randomness: particles, flicker, shake. Gameplay draws from Rng instead. -inline float random_get(float min, float max) -{ - return min + SDL_randf() * (max - min); -} - constexpr int RNG_FRACTION_BITS = 24; constexpr int RNG_SHIFT_A = 13; constexpr int RNG_SHIFT_B = 17; constexpr int RNG_SHIFT_C = 5; constexpr Uint32 RNG_SEED = 0x9E3779B9; +// Describes a spray of square particles, fanned around angle by spread. Fill is how far their colour takes over their +// black middle. +struct Burst +{ + float angle = 0; + float fill = 0; + float gravity = 0; + float life = 0.4f; + float size = 3; + float speed = 100; + float spread = PI; + int count = 8; +}; + // A small seeded xorshift generator for gameplay, saved with the simulation so a replay draws the same numbers. struct Rng { @@ -209,9 +221,3 @@ template struct FixedList count = kept; } }; - -// Absolute path to a file inside the resources folder next to the executable. -inline std::string path_get(const char* relative) -{ - return std::string(SDL_GetBasePath()) + "resources/" + relative; -} diff --git a/src/effects.cpp b/src/effects.cpp index 29fd876..7186b91 100644 --- a/src/effects.cpp +++ b/src/effects.cpp @@ -44,8 +44,6 @@ template static void effect_add(std::vector& list, const T& effe // Sprays particles at position, rotated by angle on top of the burst's own angle. void Effects::burst(Burst burst, Vec2 position, float angle, Color color) { - if (isMuted) - return; for (int i = 0; i < burst.count; i++) { float direction = angle + burst.angle + random_get(-burst.spread, burst.spread); @@ -59,8 +57,6 @@ void Effects::burst(Burst burst, Vec2 position, float angle, Color color) // Callers may ask every tick. Copies are only kept every few ticks so trails stay spaced out. void Effects::afterimage_spawn(Pose pose) { - if (isMuted) - return; if (ticks % AFTERIMAGE_INTERVAL == 0) effect_add(afterimages, Afterimage{pose, AFTERIMAGE_LIFE}); } @@ -82,8 +78,6 @@ void Effects::afterimage_draw(Game& game) // total. void Effects::number_spawn(Vec2 position, int value, int target, Color color) { - if (isMuted) - return; for (int i = (int)popups.size() - 1; i >= 0; i--) { Popup& popup = popups[i]; @@ -109,8 +103,6 @@ void Effects::number_spawn(Vec2 position, int value, int target, Color color) void Effects::popup_spawn(Vec2 position, const char* text, Color color) { - if (isMuted) - return; Popup popup = { .color = color, .position = position, @@ -125,15 +117,11 @@ void Effects::popup_spawn(Vec2 position, const char* text, Color color) void Effects::flash_spawn(Vec2 position, float angle, float size, Color color) { - if (isMuted) - return; effect_add(flashes, Flash{color, position, angle, FLASH_LIFE, size}); } void Effects::dps_record(int amount) { - if (isMuted) - return; hits.push_back({ticks * TICK, amount}); } @@ -148,8 +136,6 @@ float Effects::dps_get() void Effects::shake_add(float amount) { - if (isMuted) - return; shake = std::min(SHAKE_TRAUMA_MAX, shake + amount); } diff --git a/src/effects.h b/src/effects.h index 9ecf95f..9e26239 100644 --- a/src/effects.h +++ b/src/effects.h @@ -5,20 +5,6 @@ struct Game; -// Describes a spray of square particles, fanned around angle by spread. Fill is how far their colour takes over their -// black middle. -struct Burst -{ - float angle = 0; - float fill = 0; - float gravity = 0; - float life = 0.4f; - float size = 3; - float speed = 100; - float spread = PI; - int count = 8; -}; - struct Particle { Color color; @@ -70,13 +56,10 @@ struct Afterimage float life = 0; }; -// Particles, afterimages, text popups, screen shake and the damage-per-second tally. While muted (during a -// rollback replay) it ignores new requests. +// Particles, afterimages, text popups, screen shake and the damage-per-second tally. class Effects { public: - bool isMuted = false; - void burst(Burst burst, Vec2 position, float angle, Color color); void afterimage_spawn(Pose pose); void flash_spawn(Vec2 position, float angle, float size, Color color); diff --git a/src/fighter.cpp b/src/fighter.cpp index 1823256..717591e 100644 --- a/src/fighter.cpp +++ b/src/fighter.cpp @@ -1,9 +1,7 @@ #include "fighter.h" -#include "game.h" -#include +#include "sim.h" #include -constexpr int BLINK_MILLISECONDS = 80; constexpr float AIR_ACCELERATION = 400; constexpr float BOUNCE_SHAKE = 0.0005f; constexpr float SLAM_DAMAGE = 0.06f; @@ -12,9 +10,6 @@ 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 CHECKPOINT_ALPHA = 0.3f; -constexpr float CHECKPOINT_HEIGHT = 24; -constexpr float POUND_RING_ALPHA = 0.25f; constexpr float AFTERIMAGE_SPEED = 440; constexpr float AIR_FRICTION = 600; @@ -37,7 +32,6 @@ constexpr float LAND_SPEED = 150; constexpr float MAX_FALL = 520; constexpr float ROPE_MIN = 24; constexpr float SLIDE_DUST_INTERVAL = 0.08f; -constexpr float STEP_SPEED = 40; constexpr float SWING_ACCELERATION = 500; constexpr float SWING_SPEED_MAX = 420; constexpr float POUND_AREAS[LEVEL_COUNT] = {30, 40, 50}; @@ -59,23 +53,16 @@ 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 CRAWL_STRIDE = 5; constexpr float DOWNHILL_BOOST = 0.6f; constexpr float MOMENTUM_DRAG = 400; -constexpr float ROLL_RADIUS = 10; constexpr float SLIDE_FRICTION = 150; constexpr float SLIDE_MAX = 700; constexpr float SLIDE_MIN = 60; constexpr float TUMBLE_RATE = 0.012f; -constexpr float WALK_STRIDE = 7; // 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; -// Animations drawn facing their own way, picked by the way the fighter moves (right, then left). The rest are -// mirrored to face left. -constexpr const char* CRAWL_ANIMATIONS[] = {"Crawl Right", "Crawl Left"}; -constexpr const char* WALK_ANIMATIONS[] = {"Walk Right", "Walk Left"}; 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}; @@ -87,12 +74,12 @@ 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(Game& game, int number) +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(game, 1); + state_reset(sim, 1); } // Uses the first weapons and trinkets slots of each loadout, emptying the rest, and holds a slot in use. @@ -106,15 +93,15 @@ void Fighter::slots_set(int weapons, int trinkets) } // Back at its spawn and still: no velocity, nothing charging, no echoes, not launched or stuck in hitstop. -void Fighter::position_reset(Game& game) +void Fighter::position_reset(Sim& sim) { - position_set(game, game.world.spawns[index]); + position_set(sim, sim.world.spawns[index]); } // Puts the fighter still with its feet at a point, nothing charging. -void Fighter::position_set(Game& game, Vec2 spawn) +void Fighter::position_set(Sim& sim, Vec2 spawn) { - charge_cancel(game); + 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; @@ -125,9 +112,9 @@ void Fighter::position_set(Game& game, Vec2 spawn) // 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(Game& game, float maxHealth) +void Fighter::state_reset(Sim& sim, float maxHealth) { - position_reset(game); + position_reset(sim); std::fill(std::begin(cooldowns), std::end(cooldowns), 0.0f); flash = 0; health = maxHealth; @@ -140,11 +127,11 @@ void Fighter::state_reset(Game& game, float maxHealth) // up to a speed that grows with the damage. Knocks top up rather than stack, so rapid small hits can't juggle. A hard // enough knock launches it. Shock Absorber softens it. Running out of health kills it in a burst, and its attacks // vanish with it. -void Fighter::damage_take(Game& game, Damage damage) +void Fighter::damage_take(Sim& sim, Damage damage) { // Races and the sandbox never cost health. - bool isRacing = game.match.is_racing(); - health -= isRacing || game.match.state == MATCH_PRACTICE ? 0 : damage.amount; + bool isRacing = sim.match.is_racing(); + health -= isRacing || sim.match.state == MATCH_PRACTICE ? 0 : damage.amount; flash = 1; hitstop = std::max(hitstop, damage.hitstop); @@ -160,15 +147,15 @@ void Fighter::damage_take(Game& game, Damage damage) return; isAlive = false; - charge_cancel(game); - for (Attack& attack : game.weapons.attacks) + charge_cancel(sim); + for (Attack& attack : sim.weapons.attacks) attack.isDead |= attack.owner == index; - game.audio.play(SOUND_DEATH); - game.effects.burst(DEATH_BURST, center_get(), 0, game.color_get(index)); - game.effects.shake_add(DEATH_SHAKE); + 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(Game& game) +void Fighter::update(Sim& sim) { if (!isAlive) return; @@ -184,18 +171,18 @@ void Fighter::update(Game& game) for (float& cooldown : cooldowns) cooldown = std::max(0.0f, cooldown - TICK); kickTime += TICK; - Attack* hook = game.weapons.hook_get(*this); - slot_update(game); + 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) - game.audio.play(isCrouching ? SOUND_DUCK : SOUND_UNDUCK); + 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 = game.weapons.is_spinning(*this); + isSpinning = sim.weapons.is_spinning(*this); run_update(hook); body.isDropping = is_down(CONTROL_DOWN); @@ -204,7 +191,7 @@ void Fighter::update(Game& game) 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(game); + climb_update(sim); if (jumpBuffer > 0 && coyoteTimer > 0 && !isLaunched) { @@ -212,10 +199,10 @@ void Fighter::update(Game& game) jumpBuffer = 0; coyoteTimer = 0; isJumping = true; - game.audio.play(SOUND_JUMP); - game.effects.burst(STEP_DUST, feet, 0, game.color_get(index)); + sim.cues->sound_play(SOUND_JUMP); + sim.cues->burst_spawn(STEP_DUST, feet, 0, index); } - dash_update(game, isJumpPressed); + dash_update(sim, isJumpPressed); isJumping &= body.velocity.y < 0 && !hook; if (isJumping && !isJumpHeld && body.velocity.y < -JUMP_RELEASE_SPEED) @@ -228,12 +215,12 @@ void Fighter::update(Game& game) bool isPounding = is_down(CONTROL_DOWN) && is_trinket_equipped(TRINKET_WEIGHTS); float fallSpeed = body.velocity.y; float runSpeed = body.velocity.x; - game.world.obstacles = game.weapons.solids_get(*this); - game.world.body_move(body); - game.world.obstacles.clear(); + sim.world.obstacles = sim.weapons.solids_get(*this); + sim.world.body_move(body); + sim.world.obstacles.clear(); if (isLaunched) - bounce_resolve(game, {runSpeed, fallSpeed}); - safe_update(game); + bounce_resolve(sim, {runSpeed, fallSpeed}); + safe_update(sim); wallMemory -= TICK; bool isWallHit = body.velocity.x == 0 && std::abs(runSpeed) > WALL_IMPACT_SPEED; @@ -245,20 +232,20 @@ void Fighter::update(Game& game) if (wallMemory <= 0) wallSpeed = 0; if (vec2_length(body.velocity) > AFTERIMAGE_SPEED || dashTimer > 0) - game.effects.afterimage_spawn(pose_get(game)); + sim.cues->fighter_afterimage_spawn(*this); feet = {center_get().x, body.box.y + body.box.h}; if (body.isGrounded && !wasGrounded && fallSpeed > LAND_SPEED) { - game.audio.play(SOUND_LAND); - game.effects.burst(LAND_DUST, feet, 0, game.color_get(index)); + sim.cues->sound_play(SOUND_LAND); + sim.cues->burst_spawn(LAND_DUST, feet, 0, index); } // Iron Weights slam: the harder the landing, the harder the blast. Damage follows the landing speed, and the // radius its square root. if (body.isGrounded && !wasGrounded && fallSpeed > LAND_SPEED && isPounding) { float impact = std::clamp(fallSpeed / POUND_SPEED, POUND_SCALE_MIN, POUND_SCALE_MAX); - game.weapons.blast_spawn(game, {.position = feet, + sim.weapons.blast_spawn(sim, {.position = feet, .area = POUND_AREAS[trinket_level_get(TRINKET_WEIGHTS)] * std::sqrt(impact), .damage = trinket_sum_get(TRINKET_WEIGHTS, POUND_DAMAGES) * impact, .owner = index, @@ -288,15 +275,15 @@ void Fighter::update(Game& game) if (stepDistance <= 0) { stepDistance += isWalking ? WALK_STRIDE * WALK_STEP_FRAMES : CRAWL_STRIDE * CRAWL_STEP_FRAMES; - game.audio.play(isWalking ? SOUND_STEP : SOUND_CRAWL); + sim.cues->sound_play(isWalking ? SOUND_STEP : SOUND_CRAWL); if (isWalking) - game.effects.burst(STEP_DUST, feet, 0, game.color_get(index)); + sim.cues->burst_spawn(STEP_DUST, feet, 0, index); } } else stepDistance = 0; if (isSliding && body.isGrounded && std::floor(roll / PI) != std::floor(rollBefore / PI)) - game.audio.play(SOUND_ROLL); + 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. @@ -304,7 +291,7 @@ void Fighter::update(Game& game) if (std::abs(cursorX) > FACING_DEADZONE) isFacingLeft = cursorX < 0; aim = vec2_normalize(controls.cursor - hand_get()); - charge_update(game); + charge_update(sim); buttonsPrevious = controls.buttons; } @@ -404,7 +391,7 @@ void Fighter::rope_update(Attack& hook) // 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(Game& game) +void Fighter::slot_update(Sim& sim) { int previous = slot; if (controls.slot != -1) @@ -412,16 +399,16 @@ void Fighter::slot_update(Game& game) int held = weapon_get(); if (slot != previous) - game.audio.play(SOUND_SELECT); + sim.cues->sound_play(SOUND_SELECT); if (slot != previous && weaponLoadout[previous] >= 0 && weapon_key(previous) != weapon_key(slot)) { offSlot = previous; - game.weapons.attacks_release(*this, ATTACK_FLAIL); + 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 && game.match.state == MATCH_PRACTICE) - game.ui.level_step(game, weaponSlotLevels[slot], step); + 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. @@ -437,7 +424,7 @@ void Fighter::size_update() // 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(Game& game, bool isJumpPressed) +void Fighter::dash_update(Sim& sim, bool isJumpPressed) { float speed = trinket_best_get(TRINKET_WINGS, 0); dashTimer -= TICK; @@ -451,16 +438,16 @@ void Fighter::dash_update(Game& game, bool isJumpPressed) jumpBuffer = 0; burst_add(aim * speed); dashCooldown += DASH_COOLDOWN; - game.audio.play(SOUND_DASH); - game.effects.burst(STEP_DUST, center_get(), 0, game.color_get(index)); + 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(Game& game) +void Fighter::climb_update(Sim& sim) { int level = trinket_get(TRINKET_CLIMBING, 0); - int wall = wall_get(game); + int wall = wall_get(sim); kickTimer -= TICK; slideWall = 0; if (level == 0 || wall == 0 || body.isGrounded || isLaunched) @@ -473,20 +460,20 @@ void Fighter::climb_update(Game& game) body.velocity.y = std::min(body.velocity.y, CLING_SPEED); slideWall = wall; if (slideTimer <= 0) - game.effects.burst(SLIDE_DUST, wall_point_get(wall), wall > 0 ? PI : 0, game.color_get(index)); + 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(game, wall, KICK_VELOCITY); + wall_push(sim, wall, KICK_VELOCITY); kickTimer = KICK_COOLDOWN; isJumping = false; } if (level >= 3 && jumpBuffer > 0) { - wall_push(game, wall, WALL_JUMP_VELOCITY); + wall_push(sim, wall, WALL_JUMP_VELOCITY); jumpBuffer = 0; isJumping = true; } @@ -494,20 +481,20 @@ void Fighter::climb_update(Game& game) // 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(Game& game, int wall, Vec2 base) +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; - game.audio.play(SOUND_JUMP); - game.effects.burst(KICK_DUST, wall_point_get(wall), wall > 0 ? PI : 0, game.color_get(index)); + 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(Game& game, Vec2 before) +void Fighter::bounce_resolve(Sim& sim, Vec2 before) { float impact = 0; Vec2 normal = body.normal; @@ -532,13 +519,13 @@ void Fighter::bounce_resolve(Game& game, Vec2 before) } if (impact > 0) { - game.audio.play(SOUND_BOUNCE); - game.effects.burst(BOUNCE_DUST, center_get(), 0, game.color_get(index)); - game.effects.shake_add(impact * BOUNCE_SHAKE); + 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) - game.weapons.damage_deal(game, {.point = center_get(), + sim.weapons.damage_deal(sim, {.point = center_get(), .source = center_get(), .knock = 0, .amount = slam, @@ -550,9 +537,9 @@ void Fighter::bounce_resolve(Game& game, Vec2 before) // 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(Game& game) +void Fighter::safe_update(Sim& sim) { - World& world = game.world; + World& world = sim.world; Rect box = body.box; float feet = box.y + box.h; auto isSupported = [&](float x) { @@ -564,15 +551,15 @@ void Fighter::safe_update(Game& game) if (!world.is_touching(box, TILE_PIT)) return; - game.effects.burst(LAND_DUST, center_get(), 0, game.color_get(index)); - game.audio.play(SOUND_FALL); - charge_cancel(game); + 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; - game.effects.burst(LAND_DUST, center_get(), 0, game.color_get(index)); + sim.cues->burst_spawn(LAND_DUST, center_get(), 0, index); } // Where the player touches the wall on the given side, around hand height. @@ -583,14 +570,14 @@ Vec2 Fighter::wall_point_get(int wall) } // Which side a wall touches the player on: -1 left, 1 right, 0 neither. -int Fighter::wall_get(Game& game) +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 game.world.is_solid_at({x, middle}) || - (game.world.tile_at({x, low}) == TILE_SLOPE && game.world.is_solid_at({x, low})); + 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; @@ -603,22 +590,21 @@ int Fighter::wall_get(Game& game) // 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(Game& game) +void Fighter::charge_update(Sim& sim) { - Audio& audio = game.audio; - Attack* hook = game.weapons.hook_get(*this); + Attack* hook = sim.weapons.hook_get(*this); bool isLeftPressed = is_pressed(CONTROL_ATTACK); bool isRightPressed = is_pressed(CONTROL_CHARGE); if (is_released(CONTROL_ATTACK)) - game.weapons.wisps_release(*this); + sim.weapons.wisps_release(*this); if (hook && (isLeftPressed || isRightPressed)) { - game.weapons.attacks_release(*this, ATTACK_HOOK); + sim.weapons.attacks_release(*this, ATTACK_HOOK); isFireBlocked = isLeftPressed; return; } - if (game.weapons.is_recalled(game, *this)) + if (sim.weapons.is_recalled(sim, *this)) { isFireBlocked = isLeftPressed; return; @@ -626,7 +612,7 @@ void Fighter::charge_update(Game& game) isFireBlocked &= is_down(CONTROL_ATTACK); if (is_down(CONTROL_ATTACK) && !isFireBlocked) - game.weapons.fire(game, *this, 0); + sim.weapons.fire(sim, *this, 0); if (isRightPressed && weapon_get() >= 0) isCharging = true; @@ -636,58 +622,24 @@ void Fighter::charge_update(Game& game) float previous = charge; charge = std::min(1.0f, charge + TICK / CHARGE_TIME * trinket_get(TRINKET_DRINK, 1)); if (previous < CHARGE_VISIBLE && charge >= CHARGE_VISIBLE) - chargeVoice = audio.play(SOUND_CHARGE); + sim.cues->charge_play(*this); if (charge >= 1) { - audio.stop(chargeVoice); - chargeVoice = -1; - audio.play(SOUND_READY); - game.effects.burst(READY_BURST, hand_get(), 0, game.color_get(index)); + 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)) { - game.weapons.fire(game, *this, charge); - audio.stop(chargeVoice); - chargeVoice = -1; + sim.weapons.fire(sim, *this, charge); + sim.cues->charge_stop(*this); charge = 0; isCharging = false; } } -// Draws the fighter between its weapons, all shaking and filling in with the bonus its attacks would get right now. -// Behind it, in a race, a glow rising from the ground it would respawn on (its own only). Over it, the reach of an Iron -// Weights slam about to land. The local fighter also sees where its weapon would fire. -void Fighter::draw(Game& game) -{ - if (!isAlive) - return; - Renderer& renderer = game.renderer; - Color color = game.color_get(index); - bool isLocal = index == game.local && game.devices[index] != DEVICE_NONE; - if (isLocal && game.match.is_racing()) - for (int row = 0; row < CHECKPOINT_HEIGHT; row++) - renderer.rect_draw({std::round(safe.x - BOX_WIDTH / 2), std::round(safe.y) - row - 1, BOX_WIDTH, 1}, - color_fade(color, CHECKPOINT_ALPHA * (1 - row / CHECKPOINT_HEIGHT))); - - float fill = game.weapons.buff_get(*this); - Vec2 camera = renderer.camera; - renderer.camera = camera + game.weapons.jitter_get(fill); - Pose pose = pose_get(game); - pose.placement.flash = std::max(pose.placement.flash, fill); - game.weapons.offhand_draw(game, *this); - pose.draw(renderer); - game.weapons.held_draw(game, *this); - renderer.camera = camera; - - float pound = pound_area_get(); - if (pound > 0) - renderer.ring_draw({center_get().x, body.box.y + body.box.h}, pound, color_fade(color, POUND_RING_ALPHA)); - if (isLocal && !game.menu.isOpen && game.settings.isAimShown) - game.weapons.trajectory_draw(game, *this); -} - // 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) @@ -737,10 +689,9 @@ void Fighter::burst_add(Vec2 impulse) } // Drops any charge in progress and silences its sound. -void Fighter::charge_cancel(Game& game) +void Fighter::charge_cancel(Sim& sim) { - game.audio.stop(chargeVoice); - chargeVoice = -1; + sim.cues->charge_stop(*this); charge = 0; isCharging = false; } @@ -907,81 +858,6 @@ void Fighter::blend(float alpha) body.box.y = math_lerp(cornerPrevious.y, body.box.y, alpha); } -// The animation for what the fighter is doing: tumbling when launched, kicking off or sliding down a wall, rolling, -// crawling, crouching, jumping or falling, walking, or standing. -const char* Fighter::animation_get() -{ - if (isLaunched) - return "Hurt"; - if (kickPose > 0) - return "Kick"; - if (slideWall != 0) - return "Slide"; - if (isSliding) - return "Roll"; - if (is_crawling()) - return CRAWL_ANIMATIONS[body.velocity.x < 0]; - if (isCrouching) - return "Crouch"; - if (!body.isGrounded) - return body.velocity.y < 0 ? "Jump" : "Fall"; - if (std::abs(body.velocity.x) > STEP_SPEED) - return WALK_ANIMATIONS[body.velocity.x < 0]; - return "Idle"; -} - -static bool is_pair(const char* const (&pair)[2], const char* animation) -{ - return animation == pair[0] || animation == pair[1]; -} - -// Plays the fighter's animation for this tick. Walking and crawling cycle one frame per stride of ground covered, the -// rest play in time. Turning round mid-stride carries on the cycle in the other direction's animation. -void Fighter::animator_update(Animator& animator) -{ - const char* animation = animation_get(); - bool isWalking = is_pair(WALK_ANIMATIONS, animation); - bool isCrawling = is_pair(CRAWL_ANIMATIONS, animation); - bool isTurning = isWalking && is_pair(WALK_ANIMATIONS, animator.animation) || - isCrawling && is_pair(CRAWL_ANIMATIONS, animator.animation); - float stride = isWalking ? WALK_STRIDE : isCrawling ? CRAWL_STRIDE : 0; - float frame = animator.frame; - animator.play(animation); - if (isTurning) - animator.frame = frame; - animator.update(stride > 0 ? std::abs(body.velocity.x) * TICK / stride : TICK * animator.art->fps); -} - -// The fighter's animation placed where it is right now, in its colour: feet on the ground facing its way (walking and -// crawling face the way they move), facing away from a wall it kicks off or slides down, spinning about its middle when -// launched, and rolling as a ball resting on the ground along the surface's perpendicular. Shared with afterimages. -Pose Fighter::pose_get(Game& game) -{ - Animator& animator = game.sprites.fighters[index]; - bool isDirected = is_pair(WALK_ANIMATIONS, animator.animation) || is_pair(CRAWL_ANIMATIONS, animator.animation); - float side = is_left() && !isDirected ? -1 : 1; - Draw draw = {.color = game.color_get(index), - .position = {center_get().x, body.box.y + body.box.h}, - .scale = {side * scale, scale}, - .flash = flash}; - if (isLaunched) - { - draw.position = center_get(); - draw.angle = tumble; - } - else if (kickPose > 0) - draw.scale.x = (body.velocity.x < 0 ? -1 : 1) * scale; - else if (slideWall != 0) - draw.scale.x = -slideWall * scale; - else if (isSliding) - { - Vec2 up = body.isGrounded ? vec2_normalize({body.slope, -1}) : Vec2{0, -1}; - draw.position = draw.position + up * (ROLL_RADIUS * scale); - draw.angle = roll; - } - return animator.pose_get(draw); -} - Vec2 Fighter::center_get() { return {body.box.x + body.box.w / 2, body.box.y + body.box.h / 2}; @@ -1004,11 +880,6 @@ bool Fighter::is_crawling() return isCrouching && !isSliding && std::abs(body.velocity.x) > STEP_SPEED / 2; } -bool Fighter::is_flashing() -{ - return charge >= 1 && SDL_GetTicks() / BLINK_MILLISECONDS % 2; -} - bool Fighter::is_left() { return isFacingLeft; diff --git a/src/fighter.h b/src/fighter.h index cb1f6e4..62a0161 100644 --- a/src/fighter.h +++ b/src/fighter.h @@ -1,12 +1,16 @@ #pragma once -#include "anm2.h" #include "input.h" -#include "renderer.h" #include "trinket.h" #include "world.h" +constexpr float CRAWL_STRIDE = 5; +constexpr float ROLL_RADIUS = 10; +constexpr float STEP_SPEED = 40; +constexpr float WALK_STRIDE = 7; + struct Attack; +struct Sim; constexpr int ECHO_MAX = 64; constexpr int MAX_FIGHTERS = SPAWN_COUNT; @@ -53,6 +57,8 @@ constexpr float ABSORB_KNOCK_SHARE = 2 / 3.0f; // ground until it slows. class Fighter { + friend class Scene; + public: Body body; Controls controls; @@ -90,19 +96,18 @@ public: bool isPvp = false; bool isSliding = false; - void init(Game& game, int number); - void state_reset(Game& game, float maxHealth); - void position_reset(Game& game); - void position_set(Game& game, Vec2 feet); + void init(Sim& sim, int number); + void state_reset(Sim& sim, float maxHealth); + void position_reset(Sim& sim); + void position_set(Sim& sim, Vec2 feet); void slots_set(int weapons, int trinkets); - void damage_take(Game& game, Damage damage); - void update(Game& game); - void draw(Game& game); + void damage_take(Sim& sim, Damage damage); + void update(Sim& sim); void blend(float alpha); void impulse_add(Vec2 impulse, bool isDamped = true); void teleport_to(Vec2 centre); void burst_add(Vec2 impulse); - void charge_cancel(Game& game); + void charge_cancel(Sim& sim); int weapon_get(); int weapon_key(int weaponSlot); int off_slot_get(); @@ -119,9 +124,6 @@ public: float trinket_cooldown_get(int trinket); float knock_absorb_get(); float pound_area_get(); - const char* animation_get(); - void animator_update(Animator& animator); - Pose pose_get(Game& game); Vec2 center_get(); Vec2 hand_get(); Vec2 offhand_get(); @@ -130,9 +132,8 @@ public: bool is_pressed(Control control); bool is_released(Control control); bool is_trinket_equipped(int trinket); - int wall_get(Game& game); + int wall_get(Sim& sim); bool is_crawling(); - bool is_flashing(); bool is_left(); private: @@ -151,7 +152,6 @@ private: float rollSpin = 0; float tumble = 0; float wallSpeed = 0; - int chargeVoice = -1; int slideWall = 0; Uint16 buttonsPrevious = 0; bool isCharging = false; @@ -159,15 +159,15 @@ private: bool isFacingLeft = false; bool isJumping = false; - void slot_update(Game& game); - void safe_update(Game& game); + void slot_update(Sim& sim); + void safe_update(Sim& sim); void size_update(); - void dash_update(Game& game, bool isJumpPressed); - void climb_update(Game& game); + void dash_update(Sim& sim, bool isJumpPressed); + void climb_update(Sim& sim); Vec2 wall_point_get(int wall); - void wall_push(Game& game, int wall, Vec2 base); - void bounce_resolve(Game& game, Vec2 before); + void wall_push(Sim& sim, int wall, Vec2 base); + void bounce_resolve(Sim& sim, Vec2 before); void run_update(Attack* hook); void rope_update(Attack& hook); - void charge_update(Game& game); + void charge_update(Sim& sim); }; diff --git a/src/game.h b/src/game.h index 6038956..7ac24c2 100644 --- a/src/game.h +++ b/src/game.h @@ -2,78 +2,69 @@ #include "audio.h" #include "balance.h" -#include "bot.h" #include "chat.h" #include "menu.h" #include "effects.h" -#include "fighter.h" #include "input.h" -#include "match.h" #include "net.h" #include "renderer.h" #include "rollback.h" +#include "scene.h" #include "session.h" #include "settings.h" +#include "sim.h" #include "sprites.h" #include "text.h" -#include "trinket.h" #include "ui.h" -#include "weapon.h" -#include "world.h" constexpr float PVP_OFF_SHADE = 0.4f; constexpr float AWAY_SHADE = 0.2f; // Every manager in one place, so systems can reach each other through a single reference. -// Devices say which input plays each fighter on this screen (none for bots). Fighters, the match and rng belong to the -// simulation and are saved in snapshots. Local is the fighter this screen +// Devices say which input plays each fighter on this screen (none for bots). Sim is the game being +// played, saved in snapshots. Local is the fighter this screen // controls and follows. Camera, alpha, settings and the window are presentation. Alpha is how far drawing sits between // the previous tick and the current one. Net is the connection to the server and other players. struct Game { Audio audio; Balance balance; - Bot bot; Chat chat; Menu menu; Effects effects; - Fighter fighters[MAX_FIGHTERS]; Input input; - Match match; Net net; Renderer renderer; Rollback rollback; Settings settings; + Scene scene{*this}; Session session; + Sim sim; Sprites sprites; Text text; Ui ui; - Weapons weapons; - World world; - Rng rng; Vec2 camera; Vec2 cameraPrevious; float alpha = 1; int devices[MAX_FIGHTERS] = {DEVICE_KEYBOARD, DEVICE_NONE, DEVICE_NONE, DEVICE_NONE}; - int fighterCount = 1; int local = 0; SDL_Window* window = nullptr; Fighter& local_get() { - return fighters[local]; + return sim.fighters[local]; } // Whether a fighter is played on this screen, by its keyboard and mouse or one of its gamepads. bool is_local(int fighter) { - return fighter < fighterCount && devices[fighter] != DEVICE_NONE && !fighters[fighter].isBot; + return fighter < sim.fighterCount && devices[fighter] != DEVICE_NONE && !sim.fighters[fighter].isBot; } int local_count() { int count = 0; - for (int i = 0; i < fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) count += is_local(i); return count; } @@ -85,17 +76,17 @@ struct Game // plain (as the HUD's tint is), it's never darkened. Color color_get(int fighter, bool isPlain = false) { - if (match.state == MATCH_PRACTICE && fighterCount == 1) + if (sim.match.state == MATCH_PRACTICE && sim.fighterCount == 1) { int color = settings.color_get(); return color >= 0 ? PALETTE[color] : WHITE; } - Color color = PALETTE[match.colors[fighter]]; + Color color = PALETTE[sim.match.colors[fighter]]; // Away playing a queued game, a fighter stands faded in the lobby. - if (match.state == MATCH_PRACTICE && match.isAway[fighter] && !isPlain) + if (sim.match.state == MATCH_PRACTICE && sim.match.isAway[fighter] && !isPlain) return color_shade(color, AWAY_SHADE); - bool isHostile = fighters[fighter].isPvp && fighters[local].isPvp; - bool isPeaceful = !isPlain && fighter != local && match.state == MATCH_PRACTICE && !isHostile; + bool isHostile = sim.fighters[fighter].isPvp && sim.fighters[local].isPvp; + bool isPeaceful = !isPlain && fighter != local && sim.match.state == MATCH_PRACTICE && !isHostile; return isPeaceful ? color_shade(color, PVP_OFF_SHADE) : color; } }; diff --git a/src/main.cpp b/src/main.cpp index 8b0b719..a194ae8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -19,21 +19,21 @@ constexpr float CAMERA_FOLLOW = 0.15f; // middle, clamped to the world or centred when the world is smaller. static Vec2 camera_target_get(Game& game) { - Vec2 world = game.world.size_get(); + Vec2 world = game.sim.world.size_get(); Vec2 canvas = {CANVAS_WIDTH, CANVAS_HEIGHT}; Vec2 center = {}; int count = 0; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { - if (!game.is_local(i) || !game.fighters[i].isAlive) + if (!game.is_local(i) || !game.sim.fighters[i].isAlive) continue; - center = center + game.fighters[i].center_get(); + center = center + game.sim.fighters[i].center_get(); count++; } // Spectating, it follows whoever is being watched instead. if (game.ui.spectated >= 0) { - center = game.fighters[game.ui.spectated].center_get(); + center = game.sim.fighters[game.ui.spectated].center_get(); count = 1; } Vec2 target = (count > 0 ? center * (1.0f / count) : game.local_get().center_get()) - canvas * 0.5f; @@ -48,18 +48,20 @@ static void camera_update(Game& game) game.camera = game.camera + (camera_target_get(game) - game.camera) * CAMERA_FOLLOW; } -// One simulation tick: everything rollback saves and replays. Fighters hold still through a round's countdown. The -// menu is this screen's alone, so it updates before the tick (see frame_run), not in it. +// One tick of the game this screen shows, with what it asks of the match offline: the menu's Play, Escape (or B) +// leaving the results unless it's just closed the menu, and its player's colour. The menu is this screen's alone, so +// it updates before the tick (see frame_run), not in it. void game_tick(Game& game) { - game.match.update(game); - if (game.match.state != MATCH_COUNTDOWN) - game.bot.update(game); - for (int i = 0; i < game.fighterCount && game.match.state != MATCH_COUNTDOWN; i++) - game.fighters[i].update(game); - game.weapons.update(game); - game.world.update(game); - game.input.tick_end(); + Match& match = game.sim.match; + Input& input = game.input; + match.isOnline = game.session.isActive; + match.isPlayRequested = input.isPlayRequested; + match.isSandboxRequested = !game.menu.isOpen && !game.menu.isEscapeTaken && + (input.is_pressed(SDL_SCANCODE_ESCAPE) || input.is_pad_tapped(SDL_GAMEPAD_BUTTON_EAST)); + match.colorWanted = game.settings.color_get(); + game.sim.tick(); + input.tick_end(); } // Steps what only this screen sees, once per live tick: particles, HUD fades and the following camera. @@ -67,7 +69,7 @@ static void presentation_update(Game& game) { game.effects.update(); game.ui.update(game); - game.sprites.update(game); + game.scene.update(); game.cameraPrevious = game.camera; camera_update(game); } @@ -77,50 +79,37 @@ static void presentation_update(Game& game) static void game_draw(Game& game) { Rect boxes[MAX_FIGHTERS]; - std::vector attacks = game.weapons.attacks; - for (int i = 0; i < game.fighterCount; i++) + std::vector attacks = game.sim.weapons.attacks; + for (int i = 0; i < game.sim.fighterCount; i++) { - boxes[i] = game.fighters[i].body.box; - game.fighters[i].blend(game.alpha); + boxes[i] = game.sim.fighters[i].body.box; + game.sim.fighters[i].blend(game.alpha); } - game.weapons.blend(game.alpha); + game.sim.weapons.blend(game.alpha); Vec2 camera = vec2_lerp(game.cameraPrevious, game.camera, game.alpha); if (game.settings.isShaking) camera = camera + game.effects.shake_get(); game.renderer.camera = {std::round(camera.x), std::round(camera.y)}; - game.world.draw(game); - game.bot.draw(game); - game.effects.afterimage_draw(game); - game.weapons.blast_draw(game); - for (int i = 0; i < game.fighterCount; i++) - game.fighters[i].draw(game); - game.weapons.draw(game); - if (game.settings.isCollisionShown) - game.weapons.collision_draw(game); + game.scene.draw(); game.effects.draw(game); game.ui.draw(game); game.renderer.present(); - for (int i = 0; i < game.fighterCount; i++) - game.fighters[i].body.box = boxes[i]; - game.weapons.attacks = attacks; + for (int i = 0; i < game.sim.fighterCount; i++) + game.sim.fighters[i].body.box = boxes[i]; + game.sim.weapons.attacks = attacks; } -// Sets up everything the simulation needs: the world, the bot graph, fighters, weapons, trinkets and the seeded -// generator (RPG_SEED, every number its own odd state, or the clock). Without a renderer the sprites come back empty, -// which the simulation never needs. +// Sets up the game, seeded from RPG_SEED (every number its own odd state) or the clock. Its cues go nowhere until a +// window shows them. static void simulation_init(Game& game) { - game.world.init(game); - game.bot.init(game); - for (int i = 0; i < MAX_FIGHTERS; i++) - game.fighters[i].init(game, i); + const char* seed = SDL_getenv("RPG_SEED"); + game.sim.init(seed ? (Uint32)SDL_atoi(seed) * 2 + 1 : (Uint32)SDL_GetPerformanceCounter() | 1); game.local_get().isBot = SDL_getenv("RPG_BOT_LOCAL"); game.rollback.init(); - const char* seed = SDL_getenv("RPG_SEED"); - game.rng.state = seed ? (Uint32)SDL_atoi(seed) * 2 + 1 : (Uint32)SDL_GetPerformanceCounter() | 1; } // The frame clock: when the last frame began, and the time not yet simulated. @@ -173,9 +162,9 @@ static bool frame_run(bool isDrawn = true) continue; } session.view_leave(game); - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = game.sim.fighters[i]; bool isMenuOpen = game.menu.isOpen && i == game.local; if (game.is_local(i)) fighter.controls = game.input.controls_get(game.devices[i], game.camera, fighter.center_get(), @@ -265,6 +254,7 @@ int main(int argc, char** argv) game.ui.init(game); game.sprites.init(game); simulation_init(game); + game.sim.cues = &game.scene; game.renderer.atlas_upload(); game.settings.apply(game); game.settings.restore(game); diff --git a/src/match.cpp b/src/match.cpp index 7cb74e6..29cd821 100644 --- a/src/match.cpp +++ b/src/match.cpp @@ -1,7 +1,5 @@ #include "match.h" -#include "game.h" -#include -#include +#include "sim.h" #include constexpr Burst FINISH_BURST = {.gravity = 200, .life = 1, .size = 3, .speed = 240, .count = 40}; @@ -70,82 +68,77 @@ static int level_get(ItemRules& items, int round) return std::clamp(items.startLevel - 1 + (items.isLevelUp ? round : 0), 0, LEVEL_COUNT - 1); } -void Match::update(Game& game) +void Match::update(Sim& sim) { timer -= TICK; - // The menu's Play starts a game, and Escape (or B) leaves the results for the sandbox, unless it's just closed the - // menu. - Input& input = game.input; - bool isOver = state == MATCH_OVER; - // Online, this screen's keys can't change the shared game (starting matches online is the host's, through the - // session). - bool isLocal = !game.session.isActive; - bool isPlay = isLocal && input.isPlayRequested; - bool isSandbox = isLocal && isOver && !game.menu.isOpen && !game.menu.isEscapeTaken && - (input.is_pressed(SDL_SCANCODE_ESCAPE) || input.is_pad_tapped(SDL_GAMEPAD_BUTTON_EAST)); + // Offline, the menu's Play starts a game, and leaving the results goes back to the sandbox. + bool isPlay = !isOnline && isPlayRequested; + bool isSandbox = !isOnline && state == MATCH_OVER && isSandboxRequested; + isPlayRequested = false; + isSandboxRequested = false; if (state != MATCH_PRACTICE) - leaves_take(game); + leaves_take(sim); switch (state) { case MATCH_PRACTICE: case MATCH_OVER: // Online, after a queued game, Ready says to play it again (see Session). - for (int i = 0; i < game.fighterCount && state == MATCH_OVER && game.session.isActive; i++) - if (game.fighters[i].is_pressed(CONTROL_READY) && !isGone[i]) + for (int i = 0; i < sim.fighterCount && state == MATCH_OVER && isOnline; i++) + if (sim.fighters[i].is_pressed(CONTROL_READY) && !isGone[i]) { isReady[i] = !isReady[i]; - game.audio.play(isReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP); + sim.cues->sound_play(isReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP); } // In the sandbox, every fighter's edits land, and Ready (R) puts a fighter back at its spawn, still, with // its attacks cleared away. (Keys typed into a text field never reach the controls.) // Online, Ready in a queued game's area readies up for it instead. if (state == MATCH_PRACTICE) - queues_update(game); - for (int i = 0; i < game.fighterCount && state == MATCH_PRACTICE; i++) + queues_update(sim); + for (int i = 0; i < sim.fighterCount && state == MATCH_PRACTICE; i++) { - Fighter& fighter = game.fighters[i]; - edit_take(game, fighter); + Fighter& fighter = sim.fighters[i]; + edit_take(sim, fighter); if (!fighter.is_pressed(CONTROL_READY)) continue; if (queuedIn[i] >= 0) { isQueueReady[i] = !isQueueReady[i]; - game.audio.play(isQueueReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP); + sim.cues->sound_play(isQueueReady[i] ? SOUND_EQUIP : SOUND_UNEQUIP); continue; } - fighter.position_reset(game); - std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); + fighter.position_reset(sim); + std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); } if (isPlay) - start(game); + start(sim); else if (isSandbox && state == MATCH_OVER) - sandbox_return(game); + sandbox_return(sim); break; case MATCH_COUNTDOWN: { // Ready sticks once pressed, and bots always are. Once everyone is, the wait drops to its last second. // Fighters choosing items pick them through their controls. bool isEveryone = true; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; - edit_take(game, fighter); + Fighter& fighter = sim.fighters[i]; + edit_take(sim, fighter); isReady[i] |= fighter.isBot || fighter.is_down(CONTROL_READY); isEveryone &= isReady[i]; } if (isEveryone) timer = std::min(timer, COUNTDOWN_TIME); if (timer <= 0) - fight_start(game); + fight_start(sim); break; } case MATCH_FIGHT: clock += TICK; if (rules.mode == MODE_RACE) - race_update(game); + race_update(sim); else - deathmatch_update(game); + deathmatch_update(sim); break; case MATCH_ROUND_OVER: if (timer > 0) @@ -157,93 +150,87 @@ void Match::update(Game& game) break; } round += winner >= 0; - round_start(game, false); + round_start(sim, false); break; } } // Loads the arena (without the dummy) or builds a fresh course, maps it for the bots, sets everyone's slots to the // rules' and starts the first round. In single player, every fighter but this screen's is a bot. -void Match::start(Game& game) +void Match::start(Sim& sim) { if (state == MATCH_PRACTICE) - sandbox = game.local_get(); + sandbox = sim.fighters[0]; winner = -1; - game.fighterCount = rules.players; + int humans = sim.fighterCount; + sim.fighterCount = rules.players; if (rules.mode == MODE_RACE) - game.world.course_build(game.rng); + sim.world.course_build(sim.rng); else - game.world.arena_load(); - game.bot.graph_build(game.world); + sim.world.arena_load(); + sim.bot.graph_build(sim.world); std::fill(std::begin(wins), std::end(wins), 0); round = 0; - // Online (a queued game), the bots are whoever no member plays, and everyone's colour was settled already. - bool isOnline = game.session.isActive; - for (int i = 0; i < game.fighterCount; i++) + // Offline, the fighters added beyond the sandbox's are bots. Online (a queued game), the bots are whoever no member + // plays, and everyone's colour was settled already. + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; - fighter.isBot = isOnline ? owners[i] < 0 : game.devices[i] == DEVICE_NONE || (i == game.local && fighter.isBot); + Fighter& fighter = sim.fighters[i]; + fighter.isBot = isOnline ? owners[i] < 0 : i >= humans || fighter.isBot; fighter.slots_set(rules.weapons.slots, rules.trinkets.slots); } if (!isOnline) - colors_assign(game); - // The menu closes for whoever plays it on this screen (online, a queued game starting doesn't close a bystander's). - if (game.local_count() > 0) - game.menu.isOpen = false; - round_start(game, true); + colors_assign(sim); + sim.cues->match_start(); + round_start(sim, true); } -// Another player on this screen joins the sandbox on a gamepad, starting from this screen's sandbox loadout and -// settings, with PvP off. -void Match::sandbox_join(Game& game, int device) +// Another player on this screen joins the sandbox, starting from the first fighter's sandbox loadout and settings, with +// PvP off. Returns its fighter, or -1 when there's no room. +int Match::sandbox_join(Sim& sim) { - if (game.fighterCount >= MAX_FIGHTERS) - return; - int index = game.fighterCount++; - Fighter& fighter = game.fighters[index]; - fighter = game.local_get(); - fighter.init(game, index); - Fighter& host = game.local_get(); + if (sim.fighterCount >= MAX_FIGHTERS) + return -1; + int index = sim.fighterCount++; + Fighter& fighter = sim.fighters[index]; + Fighter& host = sim.fighters[0]; + fighter = host; + fighter.init(sim, index); std::copy(std::begin(host.weaponLoadout), std::end(host.weaponLoadout), fighter.weaponLoadout); std::copy(std::begin(host.trinketLoadout), std::end(host.trinketLoadout), fighter.trinketLoadout); + fighter.isBot = false; fighter.isPvp = false; fighter.controls = {}; - game.devices[index] = device; - game.input.devices[device].isClaimed = true; - colors_assign(game); - game.audio.play(SOUND_EQUIP); + colors_assign(sim); + sim.cues->sound_play(SOUND_EQUIP); + return index; } // A fighter leaves the sandbox with its attacks, and the ones after it move down a place. -void Match::sandbox_leave(Game& game, int index) +void Match::sandbox_leave(Sim& sim, int index) { - int device = game.devices[index]; - if (device > DEVICE_KEYBOARD) - game.input.devices[device].isClaimed = false; - std::vector& attacks = game.weapons.attacks; + std::vector& attacks = sim.weapons.attacks; std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; }); for (Attack& attack : attacks) attack.owner -= attack.owner > index; - for (int i = index; i + 1 < game.fighterCount; i++) + for (int i = index; i + 1 < sim.fighterCount; i++) { - game.fighters[i] = game.fighters[i + 1]; - game.fighters[i].index = i; - game.devices[i] = game.devices[i + 1]; + sim.fighters[i] = sim.fighters[i + 1]; + sim.fighters[i].index = i; } - game.fighterCount--; - game.devices[game.fighterCount] = DEVICE_NONE; - colors_assign(game); - game.audio.play(SOUND_UNEQUIP); + sim.fighterCount--; + colors_assign(sim); + sim.cues->sound_play(SOUND_UNEQUIP); } // Gives every fighter a colour of their own: the one they asked for if it's free (the earlier fighter keeps it if two -// ask for the same), then the rest in palette order. Only this screen's player asks for now. -void Match::colors_assign(Game& game) +// ask for the same), then the rest in palette order. Only the first fighter asks for now (offline, this screen's). +void Match::colors_assign(Sim& sim) { int wanted[MAX_FIGHTERS]; std::fill(std::begin(wanted), std::end(wanted), COLOR_UNASSIGNED); - wanted[game.local] = game.settings.color_get(); + wanted[0] = colorWanted; bool isTaken[COLOR_COUNT] = {}; for (int i = 0; i < MAX_FIGHTERS; i++) { @@ -265,14 +252,14 @@ void Match::colors_assign(Game& game) // Sets everyone's levels and health for the round, and hands out items if it's their round: dealt at random, or // emptied for choosing in the menu (bots are dealt theirs) with the menu opened to them. -void Match::round_start(Game& game, bool isFirst) +void Match::round_start(Sim& sim, bool isFirst) { isWeaponPick = is_pick_round(rules.weapons, round, isFirst); isTrinketPick = is_pick_round(rules.trinkets, round, isFirst); - game.weapons.attacks.clear(); - for (int i = 0; i < game.fighterCount; i++) + sim.weapons.attacks.clear(); + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; fighter.levels_fill(level_get(rules.weapons, round), level_get(rules.trinkets, round)); // Mirrored items are dealt once, to the first fighter, and copied to the rest below. Bots choose nothing then. if (isWeaponPick) @@ -281,7 +268,7 @@ void Match::round_start(Game& game, bool isFirst) bool isDealt = rules.weapons.isMirror ? i == 0 && !rules.weapons.isChoice : !rules.weapons.isChoice || fighter.isBot; if (isDealt) - picks_deal(game.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, + picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, rules.weapons.isDuplicates); } if (isTrinketPick) @@ -290,21 +277,21 @@ void Match::round_start(Game& game, bool isFirst) bool isDealt = rules.trinkets.isMirror ? i == 0 && !rules.trinkets.isChoice : !rules.trinkets.isChoice || fighter.isBot; if (isDealt) - picks_deal(game.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, + picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, rules.trinkets.isDuplicates); } - fighter.state_reset(game, health_get()); + fighter.state_reset(sim, health_get()); // Someone who left a queued game stays out of it. fighter.isAlive &= !isGone[i]; } if (isWeaponPick && rules.weapons.isMirror) - mirror_copy(game, false); + mirror_copy(sim, false); if (isTrinketPick && rules.trinkets.isMirror) - mirror_copy(game, true); + mirror_copy(sim, true); isWeaponPick &= rules.weapons.isChoice; isTrinketPick &= rules.trinkets.isChoice; - if ((isWeaponPick || isTrinketPick) && game.local_count() == 1) - game.menu.choice_open(game); + if (isWeaponPick || isTrinketPick) + sim.cues->choice_open(); std::fill(std::begin(isReady), std::end(isReady), false); std::fill(std::begin(finishTimes), std::end(finishTimes), -1.0f); @@ -323,7 +310,7 @@ void Match::round_start(Game& game, bool isFirst) // Applies the edit in a fighter's controls, if any (see Edit). In a match only Choice picks count: an item goes into // its slot as long as that kind is being chosen and the slot is in use, leaving any other slot it was in unless // duplicates are allowed, and mirrored items go into every fighter's slot. The sandbox takes every kind of edit. -void Match::edit_take(Game& game, Fighter& fighter) +void Match::edit_take(Sim& sim, Fighter& fighter) { int code = fighter.controls.edit; if (code < 0) @@ -354,16 +341,16 @@ void Match::edit_take(Game& game, Fighter& fighter) loadout[i] = loadout[i] == item ? -1 : loadout[i]; loadout[edit.index] = item; if (kind.isMirror) - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) std::copy(loadout, loadout + count, - isTrinket ? game.fighters[i].trinketLoadout : game.fighters[i].weaponLoadout); + isTrinket ? sim.fighters[i].trinketLoadout : sim.fighters[i].weaponLoadout); return; } case EDIT_SLOT_LEVEL: if (isSandbox && edit.index < count && loadout[edit.index] >= 0) { int& copy = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[edit.index]; - game.ui.level_step(game, copy, level - copy); + level_step(sim, copy, level - copy); } return; case EDIT_ITEM_LEVEL: @@ -371,7 +358,7 @@ void Match::edit_take(Game& game, Fighter& fighter) if (!isSandbox || edit.index >= items) return; int& itemLevel = (isTrinket ? fighter.trinketItemLevels : fighter.weaponItemLevels)[edit.index]; - game.ui.level_step(game, itemLevel, level - itemLevel); + level_step(sim, itemLevel, level - itemLevel); for (int i = 0; i < SLOT_MAX; i++) if (loadout[i] == edit.index) (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[i] = itemLevel; @@ -388,7 +375,7 @@ void Match::edit_take(Game& game, Fighter& fighter) case EDIT_COLOR: { bool isTaken = edit.value >= COLOR_COUNT; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) isTaken |= i != fighter.index && colors[i] == edit.value; if (isSandbox && !isTaken) colors[fighter.index] = edit.value; @@ -398,12 +385,12 @@ void Match::edit_take(Game& game, Fighter& fighter) } // Copies the first fighter's loadout of one kind of item to every other fighter. -void Match::mirror_copy(Game& game, bool isTrinket) +void Match::mirror_copy(Sim& sim, bool isTrinket) { - Fighter& first = game.fighters[0]; - for (int i = 1; i < game.fighterCount; i++) + Fighter& first = sim.fighters[0]; + for (int i = 1; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; if (isTrinket) std::copy(first.trinketLoadout, first.trinketLoadout + SLOT_MAX, fighter.trinketLoadout); else @@ -413,20 +400,20 @@ void Match::mirror_copy(Game& game, bool isTrinket) // The countdown is over: slots still empty after choosing are dealt at random (once, and shared, when mirrored), and // the menu closes. -void Match::fight_start(Game& game) +void Match::fight_start(Sim& sim) { - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; - picks_deal(game.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, rules.weapons.isDuplicates); - picks_deal(game.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, rules.trinkets.isDuplicates); + Fighter& fighter = sim.fighters[i]; + picks_deal(sim.rng, fighter.weaponLoadout, fighter.weaponSlots, WEAPON_COUNT, rules.weapons.isDuplicates); + picks_deal(sim.rng, fighter.trinketLoadout, fighter.trinketSlots, TRINKET_COUNT, rules.trinkets.isDuplicates); if (i == 0 && rules.weapons.isMirror) - mirror_copy(game, false); + mirror_copy(sim, false); if (i == 0 && rules.trinkets.isMirror) - mirror_copy(game, true); + mirror_copy(sim, true); } - if ((isWeaponPick || isTrinketPick) && game.local_count() > 0) - game.menu.isOpen = false; + if (isWeaponPick || isTrinketPick) + sim.cues->choice_close(); isWeaponPick = false; isTrinketPick = false; clock = 0; @@ -435,14 +422,14 @@ void Match::fight_start(Game& game) // The round ends with one fighter (or none) left standing, or when time runs out with whoever has the most health, // drawn if that's shared. -void Match::deathmatch_update(Game& game) +void Match::deathmatch_update(Sim& sim) { int alive = 0; winner = -1; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - alive += game.fighters[i].isAlive; - winner = game.fighters[i].isAlive ? i : winner; + alive += sim.fighters[i].isAlive; + winner = sim.fighters[i].isAlive ? i : winner; } bool isTimeUp = time_limit_get() > 0 && clock >= time_limit_get(); if (alive > 1 && !isTimeUp) @@ -451,9 +438,9 @@ void Match::deathmatch_update(Game& game) if (alive > 1) { float best = 0; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; if (!fighter.isAlive || fighter.health < best) continue; winner = fighter.health == best ? -1 : i; @@ -464,13 +451,13 @@ void Match::deathmatch_update(Game& game) wins[winner]++; state = MATCH_ROUND_OVER; timer = ROUND_OVER_TIME; - game.audio.play(SOUND_FINISH); + sim.cues->sound_play(SOUND_FINISH); } // Whether the coming deathmatch round decides the match whoever wins it: every fighter is a win short of taking it. -bool Match::is_final_round(Game& game) +bool Match::is_final_round(Sim& sim) { - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) if (wins[i] != rules.wins_get() - 1) return false; return rules.mode != MODE_RACE; @@ -496,22 +483,22 @@ bool Match::is_racing() // Sees fighters over the finish line, then ends the race once nobody is left running, a while after the winner // finished, or when time runs out, when the racer furthest along wins if nobody has finished. -void Match::race_update(Game& game) +void Match::race_update(Sim& sim) { int running = 0; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; - if (fighter.isAlive && game.world.is_touching(fighter.body.box, TILE_FINISH)) + Fighter& fighter = sim.fighters[i]; + if (fighter.isAlive && sim.world.is_touching(fighter.body.box, TILE_FINISH)) { finishTimes[i] = clock; winner = winner < 0 ? i : winner; fighter.isAlive = false; - fighter.charge_cancel(game); - for (Attack& attack : game.weapons.attacks) + fighter.charge_cancel(sim); + for (Attack& attack : sim.weapons.attacks) attack.isDead |= attack.owner == i; - game.effects.burst(FINISH_BURST, fighter.center_get(), 0, game.color_get(i)); - game.audio.play(SOUND_FINISH); + sim.cues->burst_spawn(FINISH_BURST, fighter.center_get(), 0, i); + sim.cues->sound_play(SOUND_FINISH); } running += fighter.isAlive; } @@ -521,10 +508,10 @@ void Match::race_update(Game& game) if (running > 0 && !isGraceOver && !isTimeUp) return; float furthest = -INFINITY; - for (int i = 0; i < game.fighterCount && finishTimes[std::max(0, winner)] < 0; i++) + for (int i = 0; i < sim.fighterCount && finishTimes[std::max(0, winner)] < 0; i++) { - float x = game.fighters[i].center_get().x; - if (game.fighters[i].isAlive && x > furthest) + float x = sim.fighters[i].center_get().x; + if (sim.fighters[i].isAlive && x > furthest) { winner = i; furthest = x; @@ -534,18 +521,18 @@ void Match::race_update(Game& game) } // Back to the sandbox: the arena and its dummy, the local fighter alone with its own slots, loadout and levels again. -void Match::sandbox_return(Game& game) +void Match::sandbox_return(Sim& sim) { state = MATCH_PRACTICE; isWeaponPick = false; isTrinketPick = false; - game.fighterCount = 1; - game.world.sandbox_load(); - game.bot.graph_build(game.world); - game.weapons.attacks.clear(); - game.menu.isOpen = false; + sim.fighterCount = 1; + sim.world.sandbox_load(); + sim.bot.graph_build(sim.world); + sim.weapons.attacks.clear(); + sim.cues->sandbox_return(); - Fighter& fighter = game.local_get(); + Fighter& fighter = sim.fighters[0]; fighter.slots_set(sandbox.weaponSlots, sandbox.trinketSlots); std::copy(std::begin(sandbox.weaponLoadout), std::end(sandbox.weaponLoadout), fighter.weaponLoadout); std::copy(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), fighter.trinketLoadout); @@ -553,20 +540,20 @@ void Match::sandbox_return(Game& game) std::copy(std::begin(sandbox.trinketItemLevels), std::end(sandbox.trinketItemLevels), fighter.trinketItemLevels); std::copy(std::begin(sandbox.weaponSlotLevels), std::end(sandbox.weaponSlotLevels), fighter.weaponSlotLevels); std::copy(std::begin(sandbox.trinketSlotLevels), std::end(sandbox.trinketSlotLevels), fighter.trinketSlotLevels); - fighter.state_reset(game, 1); + fighter.state_reset(sim, 1); } // Online, opens a fighter's queued game: an area on the ground big enough for everyone it's for, as near the fighter // as there's room (on the floor below it, or the nearest stretch of floor to either side that's flat, clear overhead // and not another queue's), with the fighter standing still at its left end, readied up. -void Match::queue_open(Game& game, int fighter, const Rules& wanted) +void Match::queue_open(Sim& sim, int fighter, const Rules& wanted) { - World& world = game.world; + World& world = sim.world; Queue& queue = queues[fighter]; queue = {.rules = rules_clean(wanted), .isActive = true}; float width = queue.rules.players * QUEUE_SLOT_WIDTH + QUEUE_MARGIN * 2; Vec2 size = world.size_get(); - Fighter& queuer = game.fighters[fighter]; + Fighter& queuer = sim.fighters[fighter]; Vec2 feet = {queuer.body.box.x + queuer.body.box.w / 2, queuer.body.box.y + queuer.body.box.h}; // The top of the first floor at or below a point, if there's one. @@ -588,7 +575,7 @@ void Match::queue_open(Game& game, int fighter, const Rules& wanted) return false; } Rect area = {left, floor - QUEUE_HEIGHT, width, QUEUE_HEIGHT}; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) if (i != fighter && queues[i].isActive && is_rect_overlapping(area, queues[i].area)) return false; return true; @@ -610,40 +597,40 @@ void Match::queue_open(Game& game, int fighter, const Rules& wanted) if (isFound) break; } - queuer.position_set(game, {queue.area.x + QUEUE_MARGIN + QUEUE_SLOT_WIDTH / 2, queue.area.y + queue.area.h}); - std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == fighter; }); + queuer.position_set(sim, {queue.area.x + QUEUE_MARGIN + QUEUE_SLOT_WIDTH / 2, queue.area.y + queue.area.h}); + std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == fighter; }); queuedIn[fighter] = fighter; isQueueReady[fighter] = false; - game.audio.play(SOUND_EQUIP); + sim.cues->sound_play(SOUND_EQUIP); } // Who's in a queued game's area: the queuer first, then the rest in fighter order. Returns how many. -int Match::queue_members_get(Game& game, int queuer, int* members) +int Match::queue_members_get(Sim& sim, int queuer, int* members) { int count = 0; members[count++] = queuer; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) if (i != queuer && queuedIn[i] == queuer) members[count++] = i; return count; } // Whether a queued game's area has everyone it's for. -bool Match::is_queue_full(Game& game, int queuer) +bool Match::is_queue_full(Sim& sim, int queuer) { int members[MAX_FIGHTERS]; - return queue_members_get(game, queuer, members) >= queues[queuer].rules.players; + return queue_members_get(sim, queuer, members) >= queues[queuer].rules.players; } // Whether a queued game is set to start: everyone in its area has readied up, and either it's full or it fills its // empty places with bots (and isn't over full). The queuer readying is what starts one with bots. -bool Match::is_queue_set(Game& game, int queuer) +bool Match::is_queue_set(Sim& sim, int queuer) { Queue& queue = queues[queuer]; if (!queue.isActive || isAway[queuer]) return false; int members[MAX_FIGHTERS]; - int count = queue_members_get(game, queuer, members); + int count = queue_members_get(sim, queuer, members); bool isReadied = true; for (int i = 0; i < count; i++) isReadied &= isQueueReady[members[i]]; @@ -652,10 +639,10 @@ bool Match::is_queue_set(Game& game, int queuer) // The queued game to start now, if any: one that's been set to for a moment (so a change of mind can stop it, and // the Ready that set it is let go before its countdown, which would take it). -int Match::queue_startable_get(Game& game) +int Match::queue_startable_get(Sim& sim) { - for (int queuer = 0; queuer < game.fighterCount; queuer++) - if (is_queue_set(game, queuer) && queues[queuer].readyTicks >= QUEUE_START_TICKS) + for (int queuer = 0; queuer < sim.fighterCount; queuer++) + if (is_queue_set(sim, queuer) && queues[queuer].readyTicks >= QUEUE_START_TICKS) return queuer; return -1; } @@ -682,11 +669,11 @@ void Match::queue_slots_shift(int from, int count) // Who stands in which queued game's area this tick (anyone away playing stands in none, and a queuer always in its // own). Stepping out unreadies. A queuer can't move or attack (only crouch, and ready up), and anyone away does // nothing. -void Match::queues_update(Game& game) +void Match::queues_update(Sim& sim) { - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; if (isAway[i]) { fighter.controls = {}; @@ -698,7 +685,7 @@ void Match::queues_update(Game& game) if (queues[i].isActive) in = i; Vec2 feet = {fighter.body.box.x + fighter.body.box.w / 2, fighter.body.box.y + fighter.body.box.h}; - for (int j = 0; j < game.fighterCount && in < 0; j++) + for (int j = 0; j < sim.fighterCount && in < 0; j++) { Rect area = queues[j].area; area = {area.x - QUEUE_FEET_SLACK, area.y, area.w + QUEUE_FEET_SLACK * 2, area.h + QUEUE_FEET_SLACK}; @@ -714,27 +701,27 @@ void Match::queues_update(Game& game) fighter.controls.buttons &= kept; } } - for (int i = 0; i < game.fighterCount; i++) - queues[i].readyTicks = is_queue_set(game, i) ? queues[i].readyTicks + 1 : 0; + for (int i = 0; i < sim.fighterCount; i++) + queues[i].readyTicks = is_queue_set(sim, i) ? queues[i].readyTicks + 1 : 0; } // In a queued game, how many players (not bots) haven't left it. -int Match::humans_left_get(Game& game) +int Match::humans_left_get(Sim& sim) { int count = 0; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) count += owners[i] >= 0 && !isGone[i]; return count; } // Online, in a queued game, a player leaving it (their Leave edit): they're out for good, which counts as a KO. With // only one player left after that, they win the match outright. -void Match::leaves_take(Game& game) +void Match::leaves_take(Sim& sim) { bool isLeft = false; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; if (fighter.controls.edit < 0 || edit_decode(fighter.controls.edit).type != EDIT_LEAVE) continue; fighter.controls.edit = -1; @@ -744,18 +731,18 @@ void Match::leaves_take(Game& game) isForfeited[i] = state != MATCH_OVER; isReady[i] = false; fighter.isAlive = false; - std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); + std::erase_if(sim.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); isLeft = true; } if (!isLeft || state == MATCH_OVER) return; int left = -1; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) left = owners[i] >= 0 && !isGone[i] ? (left == -1 ? i : -2) : left; if (left == -2) return; winner = left; state = MATCH_OVER; std::fill(std::begin(isReady), std::end(isReady), false); - game.audio.play(SOUND_FINISH); + sim.cues->sound_play(SOUND_FINISH); } diff --git a/src/match.h b/src/match.h index 2e6e21a..00e7ebe 100644 --- a/src/match.h +++ b/src/match.h @@ -3,7 +3,7 @@ #include "fighter.h" #include "protocol.h" -struct Game; +struct Sim; constexpr int BEST_OF_MAX = 9; constexpr int PLAYERS_MAX = 4; @@ -197,33 +197,39 @@ public: int againTicks = 0; bool isTrinketPick = false; bool isWeaponPick = false; + // Offline, what this screen asks of the next tick: the menu's Play, leaving the results for the sandbox, and the + // colour its player picked. Online, members' wishes go through the session instead. + int colorWanted = COLOR_UNASSIGNED; + bool isOnline = false; + bool isPlayRequested = false; + bool isSandboxRequested = false; Fighter sandbox; - void update(Game& game); - void start(Game& game); - void colors_assign(Game& game); - void sandbox_return(Game& game); - void sandbox_join(Game& game, int device); - void sandbox_leave(Game& game, int fighter); + void update(Sim& sim); + void start(Sim& sim); + void colors_assign(Sim& sim); + void sandbox_return(Sim& sim); + int sandbox_join(Sim& sim); + void sandbox_leave(Sim& sim, int fighter); bool is_racing(); float health_get(); - bool is_final_round(Game& game); + bool is_final_round(Sim& sim); float time_limit_get(); - void queue_open(Game& game, int fighter, const Rules& rules); - int queue_members_get(Game& game, int queuer, int* members); - bool is_queue_full(Game& game, int queuer); - int queue_startable_get(Game& game); - bool is_queue_set(Game& game, int queuer); + void queue_open(Sim& sim, int fighter, const Rules& rules); + int queue_members_get(Sim& sim, int queuer, int* members); + bool is_queue_full(Sim& sim, int queuer); + int queue_startable_get(Sim& sim); + bool is_queue_set(Sim& sim, int queuer); void queue_slots_shift(int from, int count); - int humans_left_get(Game& game); + int humans_left_get(Sim& sim); private: - void round_start(Game& game, bool isFirst); - void fight_start(Game& game); - void edit_take(Game& game, Fighter& fighter); - void mirror_copy(Game& game, bool isTrinket); - void deathmatch_update(Game& game); - void race_update(Game& game); - void queues_update(Game& game); - void leaves_take(Game& game); + void round_start(Sim& sim, bool isFirst); + void fight_start(Sim& sim); + void edit_take(Sim& sim, Fighter& fighter); + void mirror_copy(Sim& sim, bool isTrinket); + void deathmatch_update(Sim& sim); + void race_update(Sim& sim); + void queues_update(Sim& sim); + void leaves_take(Sim& sim); }; diff --git a/src/menu.cpp b/src/menu.cpp index c8ed85c..1d97fcf 100644 --- a/src/menu.cpp +++ b/src/menu.cpp @@ -47,7 +47,7 @@ constexpr const char* TIMING_NAMES[] = {"First Round", "Every Round", "Period"}; // The header's tab strip: the whole header, less room on the right for the match's buttons. static Rect tab_area_get(Game& game, Rect header) { - bool isMatch = game.match.state != MATCH_PRACTICE; + bool isMatch = game.sim.match.state != MATCH_PRACTICE; return {header.x, header.y, header.w - (isMatch ? HEADER_BUTTON_WIDTH * 2 : 0), header.h}; } @@ -63,7 +63,7 @@ void Menu::update(Game& game) { Input& input = game.input; // Readying up while choosing closes the menu, back to the countdown with the loadout's tooltips. - Match& match = game.match; + Match& match = game.sim.match; bool isReady = match.state == MATCH_COUNTDOWN && match.isReady[game.local]; if (isReady && !wasReady && (match.isWeaponPick || match.isTrinketPick)) isOpen = false; @@ -95,7 +95,7 @@ void Menu::update(Game& game) game.audio.play(SOUND_SWAP); if (isOpen) { - game.local_get().charge_cancel(game); + game.local_get().charge_cancel(game.sim); targets_reset(game); } } @@ -190,7 +190,7 @@ void Menu::update(Game& game) int step = (int)input.is_key_control_pressed(CONTROL_LEVEL_UP) - (int)input.is_key_control_pressed(CONTROL_LEVEL_DOWN); - if (step != 0 && game.match.state == MATCH_PRACTICE) + if (step != 0 && game.sim.match.state == MATCH_PRACTICE) level_step(game, step, mouse); } @@ -292,7 +292,7 @@ void Menu::pad_update(Game& game) isOpen = false; game.audio.play(isOpen ? SOUND_SELECT : SOUND_SWAP); } - if (tab >= ITEM_TABS || game.match.state != MATCH_PRACTICE) + if (tab >= ITEM_TABS || game.sim.match.state != MATCH_PRACTICE) return; int level = (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_NORTH) - (int)input.is_pad_pressed(DEVICE_KEYBOARD, SDL_GAMEPAD_BUTTON_WEST); @@ -308,7 +308,7 @@ void Menu::hints_draw(Game& game, MenuLayout& layout) return; const char* hint = "A: Select B: Close LB/RB: Tabs"; if (tab < ITEM_TABS) - hint = game.match.state == MATCH_PRACTICE ? "A: Equip/Unequip X/Y: Level RS: Scroll Info LB/RB: Tabs" + hint = game.sim.match.state == MATCH_PRACTICE ? "A: Equip/Unequip X/Y: Level RS: Scroll Info LB/RB: Tabs" : "A: Equip/Unequip RS: Scroll Info B: Close LB/RB: Tabs"; else if (tab == TAB_PLAY && (page == PAGE_RULES || page == PAGE_QUEUE)) hint = "A: Select Left/Right: Change B: Back Y: Join"; @@ -376,7 +376,7 @@ void Menu::header_draw(Game& game, MenuLayout& layout) ui.tint); } - Match& match = game.match; + Match& match = game.sim.match; if (match.state == MATCH_PRACTICE) return; Rect button = {strip.x + strip.w, strip.y + PADDING / 2, HEADER_BUTTON_WIDTH - PADDING, strip.h - PADDING}; @@ -398,7 +398,7 @@ void Menu::header_draw(Game& game, MenuLayout& layout) isOpen = false; } else if (isForfeitArmed) - match.sandbox_return(game); + match.sandbox_return(game.sim); isForfeitArmed = !isForfeitArmed; } @@ -468,7 +468,7 @@ void Menu::items_draw(Game& game, MenuLayout& layout) described = shown; Tooltip tooltip = ui.tooltip_build(game, isTrinket, item, level); Rect area = layout.description; - bool isLevelable = game.match.state == MATCH_PRACTICE; + bool isLevelable = game.sim.match.state == MATCH_PRACTICE; if (isLevelable) area.h -= WIDGET_HEIGHT + LEVEL_ROW_GAP; ui.tooltip_page_draw(game, description, area, tooltip); @@ -513,7 +513,7 @@ void Menu::play_draw(Game& game, MenuLayout& layout) if (page == PAGE_RULES || page == PAGE_QUEUE) { bool isQueue = page == PAGE_QUEUE; - rules_draw(game, area, isQueue ? queueRules : game.match.rules, isQueue); + rules_draw(game, area, isQueue ? queueRules : game.sim.match.rules, isQueue); return; } if (page == PAGE_ONLINE) @@ -693,7 +693,7 @@ void Menu::online_draw(Game& game, Rect area) SDL_snprintf(line, sizeof(line), "- %s%s%s", name.c_str(), member.id == net.hostId ? " [Host]" : "", isSelf ? " [You]" : ""); int fighter = session.fighter_get(game, member.id); - Color color = fighter >= 0 && game.match.state == MATCH_PRACTICE ? game.color_get(fighter, true) : ui.tint; + Color color = fighter >= 0 && game.sim.match.state == MATCH_PRACTICE ? game.color_get(fighter, true) : ui.tint; text.draw(game.renderer, line, {.color = color, .position = {area.x, y + TEXT_ROW_INSET}}); float x = area.x + text.size_get(line).x + PADDING * 2; if (!isSelf && !member.isConnected) @@ -889,18 +889,32 @@ void Menu::play_button_draw(Game& game, Rect button, bool isQueue) void Menu::sandbox_players_join(Game& game) { Input& input = game.input; - if (game.match.state != MATCH_PRACTICE || game.session.isActive) + if (game.sim.match.state != MATCH_PRACTICE || game.session.isActive) return; // This runs in the tick, so it reads the presses since the last tick (taps), not since the last frame. auto is_back = [&](int device) { return (input.devices[device].taps >> SDL_GAMEPAD_BUTTON_BACK & 1) != 0; }; - for (int i = game.fighterCount - 1; i > 0; i--) - if (game.devices[i] > DEVICE_KEYBOARD && is_back(game.devices[i])) - game.match.sandbox_leave(game, i); + for (int i = game.sim.fighterCount - 1; i > 0; i--) + { + int device = game.devices[i]; + if (device <= DEVICE_KEYBOARD || !is_back(device)) + continue; + input.devices[device].isClaimed = false; + game.sim.match.sandbox_leave(game.sim, i); + std::copy(game.devices + i + 1, std::end(game.devices), game.devices + i); + game.devices[MAX_FIGHTERS - 1] = DEVICE_NONE; + } for (int device = DEVICE_KEYBOARD + 1; device < DEVICE_COUNT; device++) - if (input.devices[device].pad && !input.devices[device].isClaimed && is_back(device)) - game.match.sandbox_join(game, device); + { + if (!input.devices[device].pad || input.devices[device].isClaimed || !is_back(device)) + continue; + int index = game.sim.match.sandbox_join(game.sim); + if (index < 0) + continue; + game.devices[index] = device; + input.devices[device].isClaimed = true; + } } // Gamepads join the game from the rules: Y on an unclaimed one takes the first bot's slot, Back on a joined one hands @@ -908,7 +922,7 @@ void Menu::sandbox_players_join(Game& game) void Menu::players_join(Game& game) { Input& input = game.input; - int players = game.match.rules.players; + int players = game.sim.match.rules.players; for (int i = 0; i < MAX_FIGHTERS; i++) { int device = game.devices[i]; @@ -945,7 +959,7 @@ void Menu::online_open() void Menu::choice_open(Game& game) { isOpen = true; - tab = game.match.isWeaponPick ? TAB_WEAPONS : TAB_TRINKETS; + tab = game.sim.match.isWeaponPick ? TAB_WEAPONS : TAB_TRINKETS; targets_reset(game); } @@ -1005,7 +1019,7 @@ bool Menu::is_bars_open() // countdown that lets fighters choose either kind, and only Settings anywhere else. int Menu::tabs_get(Game& game, int* tabs) { - Match& match = game.match; + Match& match = game.sim.match; int count = 0; if (match.state == MATCH_PRACTICE) { @@ -1027,7 +1041,7 @@ int Menu::tabs_get(Game& game, int* tabs) // countdown lets fighters choose that kind (the other kind's tab is there to look at). bool Menu::is_choosable(Game& game, bool isTrinket) { - Match& match = game.match; + Match& match = game.sim.match; if (match.state == MATCH_PRACTICE) return true; return match.state == MATCH_COUNTDOWN && (isTrinket ? match.isTrinketPick : match.isWeaponPick); diff --git a/src/renderer.h b/src/renderer.h index 06d5bb7..f6ceb7d 100644 --- a/src/renderer.h +++ b/src/renderer.h @@ -1,13 +1,26 @@ #pragma once #include "common.h" -#include +#include +#include #include #include struct SDL_GLContextState; struct SDL_Window; +// Cosmetic randomness: particles, flicker, shake. Gameplay draws from Rng instead. +inline float random_get(float min, float max) +{ + return min + SDL_randf() * (max - min); +} + +// Absolute path to a file inside the resources folder next to the executable. +inline std::string path_get(const char* relative) +{ + return std::string(SDL_GetBasePath()) + "resources/" + relative; +} + // One textured quad request. Region is in atlas pixels, origin is the pivot inside the region. struct Draw { diff --git a/src/rollback.cpp b/src/rollback.cpp index ade32d6..0a96b52 100644 --- a/src/rollback.cpp +++ b/src/rollback.cpp @@ -4,9 +4,6 @@ #include #include -constexpr Uint32 FNV_OFFSET = 2166136261u; -constexpr Uint32 FNV_PRIME = 16777619u; - void Rollback::init() { isTesting = SDL_getenv("RPG_ROLLBACK_TEST"); @@ -21,103 +18,35 @@ void Rollback::update(Game& game) } int slot = tick % ROLLBACK_DEPTH; - snapshots[slot] = snapshot_save(game); + snapshots[slot] = game.sim.snapshot_save(); inputs[slot] = game.input; - for (int i = 0; i < game.fighterCount; i++) - controls[slot][i] = game.fighters[i].controls; + for (int i = 0; i < game.sim.fighterCount; i++) + controls[slot][i] = game.sim.fighters[i].controls; game_tick(game); tick++; if (tick < ROLLBACK_DEPTH) return; - Snapshot live = snapshot_save(game); + Snapshot live = game.sim.snapshot_save(); Input liveInput = game.input; - Uint32 expected = checksum_get(game); + Uint32 expected = game.sim.checksum_get(); int oldest = tick % ROLLBACK_DEPTH; - snapshot_load(game, snapshots[oldest]); - game.audio.isMuted = true; - game.effects.isMuted = true; + game.sim.snapshot_load(snapshots[oldest]); + game.scene.isMuted = true; for (int i = 0; i < ROLLBACK_DEPTH; i++) { int replayed = (oldest + i) % ROLLBACK_DEPTH; game.input = inputs[replayed]; - for (int f = 0; f < game.fighterCount; f++) - game.fighters[f].controls = controls[replayed][f]; + for (int f = 0; f < game.sim.fighterCount; f++) + game.sim.fighters[f].controls = controls[replayed][f]; game_tick(game); } - game.audio.isMuted = false; - game.effects.isMuted = false; + game.scene.isMuted = false; - Uint32 result = checksum_get(game); + Uint32 result = game.sim.checksum_get(); if (result != expected) SDL_Log("Rollback desync at tick %d: live %08x, replayed %08x", tick, expected, result); - snapshot_load(game, live); + game.sim.snapshot_load(live); game.input = liveInput; } - -Snapshot Rollback::snapshot_save(Game& game) -{ - Snapshot snapshot = {.match = game.match, - .weapons = game.weapons, - .world = game.world, - .rng = game.rng, - .fighterCount = game.fighterCount}; - std::copy(std::begin(game.fighters), std::end(game.fighters), snapshot.fighters); - std::copy(std::begin(game.bot.states), std::end(game.bot.states), snapshot.bots); - return snapshot; -} - -void Rollback::snapshot_load(Game& game, Snapshot& snapshot) -{ - std::copy(std::begin(snapshot.fighters), std::end(snapshot.fighters), game.fighters); - std::copy(std::begin(snapshot.bots), std::end(snapshot.bots), game.bot.states); - game.fighterCount = snapshot.fighterCount; - game.weapons = snapshot.weapons; - game.world = snapshot.world; - game.rng = snapshot.rng; - game.match = snapshot.match; -} - -static void hash_add(Uint32& hash, float value) -{ - Uint32 bits; - std::memcpy(&bits, &value, sizeof(bits)); - hash = (hash ^ bits) * FNV_PRIME; -} - -// A fingerprint of the simulation state that matters for a desync: the fighters, every attack, the dummies, the -// match and the random generator. -Uint32 Rollback::checksum_get(Game& game) -{ - Uint32 hash = FNV_OFFSET; - hash_add(hash, (float)game.match.state + game.match.timer); - for (int i = 0; i < game.fighterCount; i++) - { - Fighter& fighter = game.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 (float cooldown : fighter.cooldowns) - hash_add(hash, cooldown); - // What the menu's edits change: loadouts, levels, slot counts, stacking and PvP. - for (int slot = 0; slot < SLOT_MAX; slot++) - for (int value : {fighter.weaponLoadout[slot], fighter.trinketLoadout[slot], fighter.weaponSlotLevels[slot], - fighter.trinketSlotLevels[slot]}) - hash_add(hash, (float)value); - for (int level : fighter.weaponItemLevels) - hash_add(hash, (float)level); - for (int level : fighter.trinketItemLevels) - hash_add(hash, (float)level); - for (int value : {fighter.weaponSlots, fighter.trinketSlots, (int)fighter.isPvp}) - hash_add(hash, (float)value); - } - for (Attack& attack : game.weapons.attacks) - for (float value : {attack.position.x, attack.position.y, attack.velocity.x, attack.velocity.y, attack.angle, - attack.time, (float)attack.hitsLeft}) - hash_add(hash, value); - for (Dummy& dummy : game.world.dummies) - for (float value : {dummy.flash, dummy.wobble, dummy.wobbleSpeed}) - hash_add(hash, value); - return (hash ^ game.rng.state) * FNV_PRIME; -} diff --git a/src/rollback.h b/src/rollback.h index 59b7100..befb773 100644 --- a/src/rollback.h +++ b/src/rollback.h @@ -1,30 +1,12 @@ #pragma once -#include "bot.h" -#include "menu.h" #include "input.h" -#include "match.h" -#include "fighter.h" -#include "trinket.h" -#include "weapon.h" -#include "world.h" +#include "sim.h" struct Game; constexpr int ROLLBACK_DEPTH = 8; -// Everything a simulation tick reads and writes, so the game can be rewound to it. -struct Snapshot -{ - BotState bots[MAX_FIGHTERS]; - Fighter fighters[MAX_FIGHTERS]; - Match match; - Weapons weapons; - World world; - Rng rng; - int fighterCount = 0; -}; - // Steps the simulation. With RPG_ROLLBACK_TEST set it also keeps the last few ticks' snapshots, inputs and fighter // controls, and every tick rewinds to the oldest, replays them muted and logs if the result differs from what happened // live. @@ -33,9 +15,6 @@ class Rollback public: void init(); void update(Game& game); - Snapshot snapshot_save(Game& game); - void snapshot_load(Game& game, Snapshot& snapshot); - Uint32 checksum_get(Game& game); private: Snapshot snapshots[ROLLBACK_DEPTH]; diff --git a/src/scene.cpp b/src/scene.cpp new file mode 100644 index 0000000..93ae0c8 --- /dev/null +++ b/src/scene.cpp @@ -0,0 +1,975 @@ +#include "scene.h" +#include "game.h" +#include +#include +#include +#include + +constexpr int BLINK_MILLISECONDS = 80; + +constexpr float TRAJECTORY_ALPHA = 0.12f; +constexpr float TRAJECTORY_TAPER = 0.25f; + +constexpr float LINK_WIDTH = 1; +constexpr float SURFACE_WIDTH = 2; + +constexpr float BLOCK_WARN = 1; +constexpr Vec2 CORNER_DIRECTIONS[] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}}; +constexpr int CORNER_SIZE = 2; +constexpr float RAMP_OUTLINE = 2; + +constexpr float CHECKPOINT_ALPHA = 0.3f; +constexpr float CHECKPOINT_HEIGHT = 24; +// Animations drawn facing their own way, picked by the way the fighter moves (right, then left). The rest are +// mirrored to face left. +constexpr const char* CRAWL_ANIMATIONS[] = {"Crawl Right", "Crawl Left"}; +constexpr float POUND_RING_ALPHA = 0.25f; +constexpr const char* WALK_ANIMATIONS[] = {"Walk Right", "Walk Left"}; + +constexpr float ARROW_FADE = 0.6f; +constexpr Burst AURA_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.3f, .count = 1}; +constexpr float BEAM_INNER = 4; +constexpr float BEAM_OUTER = 10; +constexpr float BOLT_JITTER = 4; +constexpr float BOLT_SEGMENT = 8; +constexpr float BOLT_WIDTH = 2; +constexpr float BOOMERANG_SPIN = 20; +constexpr float CHAIN_SPACING = 8; +// How hard a charging weapon shakes against an attack filled in as far. +constexpr float CHARGE_SHAKE = 0.5f; +constexpr float COLLISION_ALPHA = 0.25f; +constexpr float EXPLOSION_FLASH = 0.05f; +constexpr float EXPLOSION_GROW = 4; +constexpr float EXPLOSION_OUTLINE = 2; +constexpr float FALLOFF_CURVE = 1.5f; +constexpr float FALLOFF_DIM = 0.15f; +constexpr float FALLOFF_STEP = 6; +constexpr float FILL_JITTER = 2; +constexpr float FILL_PARTICLES = 60; +constexpr float FLAME_FADE = 3; +constexpr float FLAME_GROW = 0.8f; +constexpr float FLAME_SHRINK = 0.6f; +constexpr float FLAME_STAGGER = 1.12f; +constexpr float GRAPPLE_HANDLE = 7; +constexpr float HELD_ICON_SCALE = 0.6f; +constexpr float KICK_DECAY = 0.82f; +constexpr float KICK_SWEEP = 0.1f; +constexpr float MINE_BLINK = 0.6f; +constexpr float MINE_BLINK_ON = 0.1f; +constexpr float PIP_GAP = 3; +constexpr float PIP_SIZE = 2; +constexpr float PIP_SPIN = 8; +constexpr float RELOAD_DROP = 5; +constexpr float RELOAD_PUMP = 4; +constexpr float RELOAD_START = 0.2f; +constexpr float ROPE_INNER = 1; +constexpr float ROPE_OUTER = 3; +constexpr float TELEPORT_CORE = 0.4f; +constexpr float TELEPORT_RING_ALPHA = 0.35f; +constexpr float WHIP_INNER = 2; +constexpr float WHIP_JITTER = 0.5f; +constexpr float WHIP_OUTER = 5; +constexpr float WISP_FRAME_STAGGER = 0.3f; + +// A random offset for an empowered attack or fighter to shake by, wider the more its colour fills in, like a damage +// number. +static Vec2 jitter_get(float fill) +{ + float reach = FILL_JITTER * fill; + return {random_get(-reach, reach), random_get(-reach, reach)}; +} + +// Sheds particles off the edge of an empowered shape, more the more its colour fills in. They take the shape's colour +// and fill in just as far. +static void aura_spawn(Game& game, Capsule shape, float fill, Color color) +{ + Burst burst = AURA_BURST; + burst.fill = fill; + int count = FILL_PARTICLES * fill * TICK + random_get(0, 1); + for (int i = 0; i < count; i++) + { + float angle = random_get(-PI, PI); + Vec2 point = vec2_lerp(shape.from, shape.to, random_get(0, 1)) + vec2_polar(angle, shape.radius); + game.effects.burst(burst, point, angle, color); + } +} + +// A colour decayed to match a weakened strength: drained toward grey and darkened, faster than the damage drops so +// weakened shots read clearly. +static Color color_dim(Color color, float strength) +{ + strength = math_pow(std::min(1.0f, strength), FALLOFF_CURVE); + float grey = (color.r + color.g + color.b) / 3; + Color washed = {math_lerp(grey, color.r, strength), math_lerp(grey, color.g, strength), + math_lerp(grey, color.b, strength), color.a}; + return color_shade(washed, FALLOFF_DIM + (1 - FALLOFF_DIM) * strength); +} + +// The owner's colour (darker for secondary attacks), dimming as a shot's damage falls off. +static Color owner_color_get(Game& game, Attack& attack) +{ + return color_shade(game.color_get(attack.owner), attack.isSecondary ? SECONDARY_SHADE : 1); +} + +static Color tint_get(Game& game, Attack& attack) +{ + return color_dim(owner_color_get(game, attack), strength_get(attack, attack.tip)); +} + +// A path drawn as an outlined line: an outer width around a black core of the inner width, kinked by jitter. +struct Stroke +{ + std::vector points; + float outer = 0; + float inner = 0; + float jitter = 0; +}; + + +// A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Beams +// start as far along as they've travelled. Jitter kinks it at every piece, moving each frame so it crackles. +static void strength_line_draw(Game& game, Attack& attack, Stroke stroke) +{ + Color color = owner_color_get(game, attack); + float base = attack.bonus * (1 + attack.ricochets * RICOCHET_BONUS); + Vec2 shake = jitter_get(fill_get(base)); + std::vector points; + std::vector distances; + float distance = attack.def()->kind == ATTACK_BEAM ? attack.travelled : 0; + for (size_t i = 0; i + 1 < stroke.points.size(); i++) + { + Vec2 from = stroke.points[i]; + Vec2 delta = stroke.points[i + 1] - from; + Vec2 normal = vec2_normalize({-delta.y, delta.x}); + float length = vec2_length(delta); + int steps = length / FALLOFF_STEP + 1; + for (int step = 0; step < steps; step++) + { + bool isEnd = i == 0 && step == 0; + float nudge = isEnd ? 0 : random_get(-stroke.jitter, stroke.jitter); + points.push_back(from + delta * ((float)step / steps) + normal * nudge + shake); + distances.push_back(distance + length * (step + 0.5f) / steps); + } + distance += length; + } + if (stroke.points.empty()) + return; + points.push_back(stroke.points.back() + shake); + + for (int layer = 0; layer < 2; layer++) + { + for (size_t i = 0; i + 1 < points.size(); i++) + { + float strength = base * falloff_at(distances[i]); + if (layer == 0) + game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength)); + else + game.renderer.line_draw(points[i], points[i + 1], stroke.inner, color_shade(color, fill_get(strength))); + } + } +} + +// A kinked line from one end of the capsule to the other, its radius as the width. Each kink is nudged sideways +// by up to jitter. +static void bolt_draw(Renderer& renderer, Capsule bolt, float jitter, Color color) +{ + Vec2 delta = bolt.to - bolt.from; + Vec2 normal = vec2_normalize({-delta.y, delta.x}); + int kinks = vec2_length(delta) / BOLT_SEGMENT + 1; + Vec2 previous = bolt.from; + + for (int k = 1; k <= kinks; k++) + { + float nudge = k == kinks ? 0 : random_get(-jitter, jitter); + Vec2 point = bolt.from + delta * ((float)k / kinks) + normal * nudge; + renderer.line_draw(previous, point, bolt.radius, color); + previous = point; + } +} + +// Fills every pixel inside any of the capsules, a row of runs at a time, so where they overlap stays even. +static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color color) +{ + Rect bounds = {INFINITY, INFINITY, -INFINITY, -INFINITY}; + for (int i = 0; i < count; i++) + { + Capsule& shape = shapes[i]; + bounds.x = std::min({bounds.x, shape.from.x - shape.radius, shape.to.x - shape.radius}); + bounds.y = std::min({bounds.y, shape.from.y - shape.radius, shape.to.y - shape.radius}); + bounds.w = std::max({bounds.w, shape.from.x + shape.radius, shape.to.x + shape.radius}); + bounds.h = std::max({bounds.h, shape.from.y + shape.radius, shape.to.y + shape.radius}); + } + auto isInside = [&](Vec2 point) { + for (int i = 0; i < count; i++) + { + Vec2 line = shapes[i].to - shapes[i].from; + float length = vec2_dot(line, line); + float t = length > 0 ? std::clamp(vec2_dot(point - shapes[i].from, line) / length, 0.0f, 1.0f) : 0; + if (vec2_length(point - (shapes[i].from + line * t)) <= shapes[i].radius) + return true; + } + return false; + }; + for (float y = std::floor(bounds.y); y <= bounds.h; y++) + { + float start = -INFINITY; + for (float x = std::floor(bounds.x); x <= bounds.w + 1; x++) + { + bool isIn = x <= bounds.w && isInside({x + 0.5f, y + 0.5f}); + if (isIn && start == -INFINITY) + start = x; + if (!isIn && start != -INFINITY) + { + renderer.rect_draw({start, y, x - start, 1}, color); + start = -INFINITY; + } + } + } +} + +// The held weapon's turn away from the aim: a quick eased sweep from the kick to the follow-through, which then +// settles back to the aim. +static float kick_get(Fighter& fighter) +{ + if (fighter.kickTime < KICK_SWEEP) + { + float t = fighter.kickTime / KICK_SWEEP; + return math_lerp(fighter.kickFrom, fighter.kickTo, t * (2 - t)); + } + return fighter.kickTo * math_pow(KICK_DECAY, (fighter.kickTime - KICK_SWEEP) / TICK); +} + +// A reloading weapon's pose through its cooldown: once the kick has played out it tips up to stand vertical, dips +// down and back like a pump, then swings back to the aim just as it's ready. +static void reload_pose(Fighter& fighter, int key, float cooldown, Draw& draw) +{ + if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || cooldown <= 0) + return; + float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1); + float t = std::clamp((1 - cooldown / total - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f); + float weight = math_sin(PI * t); + draw.angle = angle_lerp(draw.angle, -PI / 2, weight); + draw.position.y += RELOAD_DROP * math_pow(weight, RELOAD_PUMP); +} + +// How far a fighter's weapons fill in with its colour: with the charge as it builds, or the speed bonus if that's more, +// then once the charge is ready blinking between full and the speed bonus alone. +static float charge_fill_get(Game& game, Fighter& fighter) +{ + float buff = game.sim.weapons.buff_get(fighter); + if (fighter.charge >= 1) + return game.scene.is_flashing(fighter) ? 1.0f : buff; + return std::max(buff, fighter.charge); +} + +static bool is_pair(const char* const (&pair)[2], const char* animation) +{ + return animation == pair[0] || animation == pair[1]; +} + +Scene::Scene(Game& game) : game(game) +{ + std::fill(std::begin(chargeVoices), std::end(chargeVoices), -1); +} + +void Scene::sound_play(SoundId sound) +{ + if (!isMuted) + game.audio.play(sound); +} + +void Scene::charge_play(Fighter& fighter) +{ + if (!isMuted) + chargeVoices[fighter.index] = game.audio.play(SOUND_CHARGE); +} + +void Scene::charge_stop(Fighter& fighter) +{ + game.audio.stop(chargeVoices[fighter.index]); + chargeVoices[fighter.index] = -1; +} + +void Scene::burst_spawn(Burst burst, Vec2 position, float angle, int owner) +{ + if (!isMuted) + game.effects.burst(burst, position, angle, game.color_get(owner)); +} + +void Scene::attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) +{ + if (!isMuted) + game.effects.burst(burst, position, angle, tint_get(game, attack)); +} + +void Scene::shake_add(float amount) +{ + if (!isMuted) + game.effects.shake_add(amount); +} + +void Scene::flash_spawn(Vec2 position, float angle, float size, int owner) +{ + if (!isMuted) + game.effects.flash_spawn(position, angle, size, game.color_get(owner)); +} + +void Scene::fighter_afterimage_spawn(Fighter& fighter) +{ + if (!isMuted) + game.effects.afterimage_spawn(fighter_pose_get(fighter)); +} + +void Scene::attack_afterimage_spawn(Attack& attack) +{ + if (!isMuted) + game.effects.afterimage_spawn(attack_pose(attack)); +} + +void Scene::fighter_aura_spawn(Fighter& fighter) +{ + if (isMuted) + return; + Rect box = fighter.body.box; + float radius = box.w / 2; + aura_spawn(game, {{box.x + radius, box.y + radius}, {box.x + radius, box.y + box.h - radius}, radius}, + game.sim.weapons.buff_get(fighter), game.color_get(fighter.index)); +} + +void Scene::attack_aura_spawn(Attack& attack) +{ + if (!isMuted) + aura_spawn(game, {attack.position, attack.tip, attack.radius}, fill_get(strength_get(attack, attack.tip)), + tint_get(game, attack)); +} + +void Scene::number_spawn(Damage& damage) +{ + if (isMuted) + return; + game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner)); + game.effects.dps_record(damage.amount); +} + +// A level going up or down chimes, and outside the menu pops up over this screen's fighter. +void Scene::level_step(bool isUp) +{ + if (isMuted) + return; + game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN); + if (game.menu.isOpen) + return; + Fighter& fighter = game.local_get(); + Vec2 head = {fighter.center_get().x, fighter.body.box.y}; + game.effects.popup_spawn(head, isUp ? "Level Up" : "Level Down", isUp ? game.ui.tint : game.ui.shade); +} + +// The menu closes for whoever plays a starting match on this screen (online, a queued game starting doesn't close a +// bystander's). +void Scene::match_start() +{ + if (game.local_count() > 0) + game.menu.isOpen = false; +} + +void Scene::choice_open() +{ + if (game.local_count() == 1) + game.menu.choice_open(game); +} + +void Scene::choice_close() +{ + if (game.local_count() > 0) + game.menu.isOpen = false; +} + +void Scene::sandbox_return() +{ + game.menu.isOpen = false; +} + +// A fully charged fighter's weapons blink, by the clock rather than the game. +bool Scene::is_flashing(Fighter& fighter) +{ + return fighter.charge >= 1 && SDL_GetTicks() / BLINK_MILLISECONDS % 2; +} + +// Each tick, every fighter's animator plays whatever its fighter is doing. +void Scene::update() +{ + for (int i = 0; i < game.sim.fighterCount; i++) + animator_update(game.sim.fighters[i], game.sprites.fighters[i]); +} + +// The game between its last two ticks: the world, the bots' map (when debugging), afterimages, blasts under the +// fighters, the fighters, their attacks, and the hit shapes when they're shown. +void Scene::draw() +{ + world_draw(); + bot_draw(); + game.effects.afterimage_draw(game); + blasts_draw(); + for (int i = 0; i < game.sim.fighterCount; i++) + fighter_draw(game.sim.fighters[i]); + attacks_draw(); + if (game.settings.isCollisionShown) + collision_draw(); +} + +// A very faint line along each path the held weapon's attack would take if fired now, fading out over the last quarter +// of its length (so a path cut short by a wall fades too), ringing the blast it would make where it ends. +void Scene::paths_draw(Fighter& fighter) +{ + Color base = game.color_get(fighter.index); + for (Path& path : game.sim.weapons.paths_get(game.sim, fighter)) + { + int last = path.steps.size() - 1; + for (int i = 0; i < last; i++) + { + float fade = std::clamp((last - i) / (last * TRAJECTORY_TAPER), 0.0f, 1.0f); + game.renderer.line_draw(path.steps[i], path.steps[i + 1], 1, color_fade(base, TRAJECTORY_ALPHA * fade)); + } + if (path.area > 0) + game.renderer.ring_draw(path.steps.back(), path.area, color_fade(base, BLAST_RING_ALPHA)); + } +} + +void Scene::world_draw() +{ + Renderer& renderer = game.renderer; + World& world = game.sim.world; + Anm2& tileArt = game.sprites.tiles; + int startX = std::max(0, (int)std::floor(renderer.camera.x / TILE_SIZE)); + int startY = std::max(0, (int)std::floor(renderer.camera.y / TILE_SIZE)); + int endX = std::min(world.width - 1, (int)std::floor((renderer.camera.x + CANVAS_WIDTH) / TILE_SIZE)); + int endY = std::min(world.height - 1, (int)std::floor((renderer.camera.y + CANVAS_HEIGHT) / TILE_SIZE)); + + for (int y = startY; y <= endY; y++) + { + for (int x = startX; x <= endX; x++) + { + auto isOpen = [&](float dx, float dy) { + Tile neighbour = world.tile_get(x + dx, y + dy); + return neighbour != TILE_SOLID && neighbour != TILE_SLOPE; + }; + auto isEmpty = [&](int dx) { + return world.tile_get(x + dx, y) == TILE_EMPTY; + }; + Tile tile = world.tile_get(x, y); + Vec2 position = {(float)x * TILE_SIZE, (float)y * TILE_SIZE}; + + if (tile == TILE_SOLID) + { + int mask = isOpen(0, -1) | isOpen(1, 0) << 1 | isOpen(0, 1) << 2 | isOpen(-1, 0) << 3; + tileArt.draw(renderer, "Wall", mask, {.position = position}); + + for (Vec2 corner : CORNER_DIRECTIONS) + { + if (isOpen(corner.x, 0) || isOpen(0, corner.y) || !isOpen(corner.x, corner.y)) + continue; + float cornerX = corner.x < 0 ? position.x : position.x + TILE_SIZE - CORNER_SIZE; + float cornerY = corner.y < 0 ? position.y : position.y + TILE_SIZE - CORNER_SIZE; + renderer.rect_draw({cornerX, cornerY, CORNER_SIZE, CORNER_SIZE}, WHITE); + } + } + else if (tile == TILE_PLATFORM) + { + int mask = isEmpty(1) | isEmpty(-1) << 1; + tileArt.draw(renderer, "Platform", mask, {.position = position}); + } + else if (tile == TILE_PIT || tile == TILE_FINISH) + tileArt.draw(renderer, tile == TILE_PIT ? "Pit" : "Finish", 0, {.position = position}); + } + } + + // Ramps: an outline just inside the slope and its flat top, and a wall down the high end where it stands free. + for (Ramp& ramp : world.ramps) + { + if (ramp.right < renderer.camera.x || ramp.left > renderer.camera.x + CANVAS_WIDTH) + continue; + float edge = ramp.anchor + (ramp.top - ramp.base) / ramp.slope; + Vec2 low = {ramp.anchor, ramp.base}; + Vec2 high = {edge, ramp.top}; + Vec2 end = {ramp.slope < 0 ? ramp.right : ramp.left, ramp.top}; + Vec2 inset = vec2_normalize({ramp.slope, -1}) * (-RAMP_OUTLINE / 2); + renderer.line_draw(low + inset, high + inset, RAMP_OUTLINE, WHITE); + renderer.line_draw({high.x, high.y + RAMP_OUTLINE / 2}, {end.x, end.y + RAMP_OUTLINE / 2}, RAMP_OUTLINE, WHITE); + + float side = ramp.slope < 0 ? 1 : -1; + Vec2 beyond = {end.x + side * TILE_SIZE / 2, ramp.top + TILE_SIZE / 2}; + if (world.tile_at(beyond) != TILE_SOLID) + renderer.line_draw({end.x - side * RAMP_OUTLINE / 2, ramp.top}, + {end.x - side * RAMP_OUTLINE / 2, ramp.base}, RAMP_OUTLINE, WHITE); + } + + // Blocks crack as they lose health, and a broken one shows a faint outline just before it grows back. + for (Dummy& dummy : world.dummies) + { + if (dummy.tile < 0) + continue; + Vec2 position = {dummy.box.x, dummy.box.y}; + if (world.tiles[dummy.tile] == TILE_BLOCK) + { + int stage = std::min(BLOCK_STAGES - 1, (int)((1 - dummy.health / BLOCK_HEALTH) * BLOCK_STAGES)); + tileArt.draw(renderer, "Block", stage, {.position = position, .flash = dummy.flash}); + } + else if (dummy.regrow < BLOCK_WARN) + tileArt.draw(renderer, "Block", 0, + {.color = color_fade(GRAY, 1 - dummy.regrow / BLOCK_WARN), .position = position}); + } + + for (Dummy& dummy : world.dummies) + { + if (dummy.tile >= 0) + continue; + Vec2 base = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h}; + game.sprites.dummy.draw(renderer, "Idle", 0, {.position = base, .angle = dummy.wobble, .flash = dummy.flash}); + } +} + +// Debug view: bot.surfaces as lines, bot.links as thin lines from takeoff to landing, and each bot's remaining path in white. +void Scene::bot_draw() +{ + Bot& bot = game.sim.bot; + if (!bot.isDrawn) + return; + + Renderer& renderer = game.renderer; + for (Surface& surface : bot.surfaces) + renderer.line_draw({surface.left, surface.y_at(surface.left)}, {surface.right, surface.y_at(surface.right)}, + SURFACE_WIDTH, GRAY); + for (Link& link : bot.links) + renderer.line_draw({link.takeoff, bot.surfaces[link.from].y_at(link.takeoff)}, + {link.landing, bot.surfaces[link.to].y_at(link.landing)}, LINK_WIDTH, + color_fade(GRAY, GOAL_SPAN)); + for (int i = 0; i < game.sim.fighterCount; i++) + { + BotState& state = bot.states[i]; + if (!game.sim.fighters[i].isBot) + continue; + for (int step = state.pathStep; step < state.pathLength; step++) + { + Link& link = bot.links[state.path[step]]; + renderer.line_draw({link.takeoff, bot.surfaces[link.from].y_at(link.takeoff)}, + {link.landing, bot.surfaces[link.to].y_at(link.landing)}, SURFACE_WIDTH, WHITE); + } + renderer.circle_draw(state.goal, SURFACE_WIDTH * 2, WHITE); + } +} + +// Draws the fighter between its weapons, all shaking and filling in with the bonus its attacks would get right now. +// Behind it, in a race, a glow rising from the ground it would respawn on (its own only). Over it, the reach of an Iron +// Weights slam about to land. The local fighter also sees where its weapon would fire. +void Scene::fighter_draw(Fighter& fighter) +{ + if (!fighter.isAlive) + return; + Renderer& renderer = game.renderer; + Color color = game.color_get(fighter.index); + bool isLocal = fighter.index == game.local && game.devices[fighter.index] != DEVICE_NONE; + if (isLocal && game.sim.match.is_racing()) + for (int row = 0; row < CHECKPOINT_HEIGHT; row++) + renderer.rect_draw({std::round(fighter.safe.x - BOX_WIDTH / 2), std::round(fighter.safe.y) - row - 1, BOX_WIDTH, 1}, + color_fade(color, CHECKPOINT_ALPHA * (1 - row / CHECKPOINT_HEIGHT))); + + float fill = game.sim.weapons.buff_get(fighter); + Vec2 camera = renderer.camera; + renderer.camera = camera + jitter_get(fill); + Pose pose = fighter_pose_get(fighter); + pose.placement.flash = std::max(pose.placement.flash, fill); + offhand_draw(fighter); + pose.draw(renderer); + held_draw(fighter); + renderer.camera = camera; + + float pound = fighter.pound_area_get(); + if (pound > 0) + renderer.ring_draw({fighter.center_get().x, fighter.body.box.y + fighter.body.box.h}, pound, color_fade(color, POUND_RING_ALPHA)); + if (isLocal && !game.menu.isOpen && game.settings.isAimShown) + paths_draw(fighter); +} + +// The animation for what the fighter is doing: tumbling when launched, kicking off or sliding down a wall, rolling, +// crawling, crouching, jumping or falling, walking, or standing. +const char* Scene::animation_get(Fighter& fighter) +{ + if (fighter.isLaunched) + return "Hurt"; + if (fighter.kickPose > 0) + return "Kick"; + if (fighter.slideWall != 0) + return "Slide"; + if (fighter.isSliding) + return "Roll"; + if (fighter.is_crawling()) + return CRAWL_ANIMATIONS[fighter.body.velocity.x < 0]; + if (fighter.isCrouching) + return "Crouch"; + if (!fighter.body.isGrounded) + return fighter.body.velocity.y < 0 ? "Jump" : "Fall"; + if (std::abs(fighter.body.velocity.x) > STEP_SPEED) + return WALK_ANIMATIONS[fighter.body.velocity.x < 0]; + return "Idle"; +} + +// Plays the fighter's animation for this tick. Walking and crawling cycle one frame per stride of ground covered, the +// rest play in time. Turning round mid-stride carries on the cycle in the other direction's animation. +void Scene::animator_update(Fighter& fighter, Animator& animator) +{ + const char* animation = animation_get(fighter); + bool isWalking = is_pair(WALK_ANIMATIONS, animation); + bool isCrawling = is_pair(CRAWL_ANIMATIONS, animation); + bool isTurning = isWalking && is_pair(WALK_ANIMATIONS, animator.animation) || + isCrawling && is_pair(CRAWL_ANIMATIONS, animator.animation); + float stride = isWalking ? WALK_STRIDE : isCrawling ? CRAWL_STRIDE : 0; + float frame = animator.frame; + animator.play(animation); + if (isTurning) + animator.frame = frame; + animator.update(stride > 0 ? std::abs(fighter.body.velocity.x) * TICK / stride : TICK * animator.art->fps); +} + +// The fighter's animation placed where it is right now, in its colour: feet on the ground facing its way (walking and +// crawling face the way they move), facing away from a wall it kicks off or slides down, spinning about its middle when +// launched, and rolling as a ball resting on the ground along the surface's perpendicular. Shared with afterimages. +Pose Scene::fighter_pose_get(Fighter& fighter) +{ + Animator& animator = game.sprites.fighters[fighter.index]; + bool isDirected = is_pair(WALK_ANIMATIONS, animator.animation) || is_pair(CRAWL_ANIMATIONS, animator.animation); + float side = fighter.is_left() && !isDirected ? -1 : 1; + Draw draw = {.color = game.color_get(fighter.index), + .position = {fighter.center_get().x, fighter.body.box.y + fighter.body.box.h}, + .scale = {side * fighter.scale, fighter.scale}, + .flash = fighter.flash}; + if (fighter.isLaunched) + { + draw.position = fighter.center_get(); + draw.angle = fighter.tumble; + } + else if (fighter.kickPose > 0) + draw.scale.x = (fighter.body.velocity.x < 0 ? -1 : 1) * fighter.scale; + else if (fighter.slideWall != 0) + draw.scale.x = -fighter.slideWall * fighter.scale; + else if (fighter.isSliding) + { + Vec2 up = fighter.body.isGrounded ? vec2_normalize({fighter.body.slope, -1}) : Vec2{0, -1}; + draw.position = draw.position + up * (ROLL_RADIUS * fighter.scale); + draw.angle = fighter.roll; + } + return animator.pose_get(draw); +} + +void Scene::attacks_draw() +{ + Renderer& renderer = game.renderer; + + for (Attack& attack : game.sim.weapons.attacks) + { + Fighter& fighter = game.sim.fighters[attack.owner]; + Color tint = tint_get(game, attack); + AttackKind kind = attack.def()->kind; + if (kind == ATTACK_HOOK) + { + // The rope runs from the end of the grapple's base in the hand. + Vec2 hand = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); + Vec2 base = hand + vec2_polar(vec2_angle(attack.position - hand), GRAPPLE_HANDLE); + strength_line_draw(game, attack, {{base, attack.position}, ROPE_OUTER, ROPE_INNER}); + } + + if (kind == ATTACK_SPIN || kind == ATTACK_FLAIL) + { + Vec2 hand = fighter.hand_get(); + Vec2 delta = attack.position - hand; + int links = vec2_length(delta) / CHAIN_SPACING; + for (int i = 1; i < links; i++) + game.sprites.effects.draw(renderer, "Chain", 0, + {.color = tint, + .position = hand + delta * ((float)i / links), + .flash = fill_get(strength_get(attack, attack.tip))}); + } + + float progress = attack.time / attack.duration; + if (kind == ATTACK_BEAM) + { + float width = (1 - progress) * attack.scale; + strength_line_draw(game, attack, {{attack.position, attack.tip}, BEAM_OUTER * width, BEAM_INNER * width}); + } + else if (kind == ATTACK_WHIP) + { + // A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite + // rides the end of the whip, pointing the way it runs. + Vec2 shake = jitter_get(fill_get(strength_get(attack, attack.tip))); + float fade = attack.isCharged ? progress : std::max(0.0f, (progress - WHIP_UNFURL) / (1 - WHIP_UNFURL)); + float width = (1 - fade) * attack.scale; + std::vector points = + attack.isCharged ? std::vector{attack.position, attack.tip} : lash_get(attack); + Vec2 end = points.back(); + Vec2 before = points[points.size() - 2]; + strength_line_draw(game, attack, + {points, WHIP_OUTER * width, WHIP_INNER * width, attack.isCharged ? 0 : WHIP_JITTER}); + game.sprites.effects.draw(renderer, "Whip Tip", 0, + {.color = color_fade(tint, 1 - fade), + .position = end + shake, + .scale = {attack.scale, attack.scale}, + .angle = vec2_angle(end - before)}); + } + else if (kind != ATTACK_EXPLOSION) + attack_pose(attack).draw(renderer); + + // A faint ring shows how far anything that explodes would reach, and pips in the shot's colour circle it, one + // for each ricochet it has left. + if (kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_MINE) + renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA)); + for (int i = 0; i < attack.bounces && kind != ATTACK_BEAM && !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); + renderer.rect_draw({std::round(pip.x - PIP_SIZE / 2), std::round(pip.y - PIP_SIZE / 2), PIP_SIZE, PIP_SIZE}, + tint); + } + } + magic_draw(); + tesla_draw(); +} + +// The Grimoire's teleport, drawn rather than sprited: a faint ring where its blast will go with a core swelling inside +// it while it gathers. (Its wisps are drawn with the other attacks.) +void Scene::magic_draw() +{ + for (Attack& attack : game.sim.weapons.attacks) + { + AttackKind kind = attack.def()->kind; + Color color = tint_get(game, attack); + if (kind == ATTACK_TELEPORT && !attack.isDead) + { + float progress = std::min(1.0f, attack.time / attack.duration); + float area = attack.stats.area * attack.scale; + game.renderer.ring_draw(attack.position, area, color_fade(color, TELEPORT_RING_ALPHA)); + game.renderer.circle_draw(attack.position, area * TELEPORT_CORE * progress, color_fade(color, progress)); + } + } +} + +// Each link is a jagged bolt whose kinks move every frame so it crackles. +void Scene::tesla_draw() +{ + for (int i = 0; i < game.sim.fighterCount; i++) + for (Capsule& link : game.sim.weapons.chain_get(game.sim, game.sim.fighters[i])) + bolt_draw(game.renderer, {link.from, link.to, BOLT_WIDTH}, BOLT_JITTER, game.color_get(i)); +} + +// The Display Collision setting: every attack's hit shapes as the hit test sees them, and the boxes fighters and +// dummies are hit in, see-through in their owner's colour. +void Scene::collision_draw() +{ + Renderer& renderer = game.renderer; + for (Attack& attack : game.sim.weapons.attacks) + { + Capsule shapes[WHIP_RAYS]; + int count = shapes_get(game.sim, attack, shapes); + capsules_fill(renderer, shapes, count, color_fade(owner_color_get(game, attack), COLLISION_ALPHA)); + } + for (int i = 0; i < game.sim.fighterCount; i++) + if (game.sim.fighters[i].isAlive) + renderer.rect_draw(game.sim.fighters[i].body.box, color_fade(game.color_get(i), COLLISION_ALPHA)); + for (Dummy& dummy : game.sim.world.dummies) + if (dummy.tile < 0) + renderer.rect_draw(dummy.box, color_fade(WHITE, COLLISION_ALPHA)); +} + +// Explosions draw before the player, so their filled centre never hides anyone standing in the blast. +void Scene::blasts_draw() +{ + for (Attack& attack : game.sim.weapons.attacks) + { + if (attack.def()->kind != ATTACK_EXPLOSION) + continue; + float progress = attack.time / attack.duration; + float radius = attack.stats.range * std::min(1.0f, progress * EXPLOSION_GROW); + Color color = color_dim(owner_color_get(game, attack), attack.bonus); + float fill = fill_get(attack.bonus); + Vec2 center = attack.position + jitter_get(fill); + game.renderer.circle_draw(center, radius, color_fade(color, 1 - progress)); + if (attack.time > EXPLOSION_FLASH) + game.renderer.circle_draw(center, radius - EXPLOSION_OUTLINE, + color_fade(color_shade(color, fill), 1 - progress)); + } +} + +// How a sprite-based attack looks right now: its weapon, or the projectile or effect it fired. Shared by the live +// attack and its afterimages. +Pose Scene::attack_pose(Attack& attack) +{ + const AttackDef& def = *attack.def(); + float progress = attack.time / attack.duration; + float side = attack.isLeft ? -1 : 1; + Anm2& effects = game.sprites.effects; + Pose pose = {.art = &game.sprites.weapons, .animation = WEAPON_DEFS[attack.weapon].name}; + Draw& draw = pose.placement; + draw = {.position = attack.position, .scale = {attack.scale, attack.scale * side}, .angle = attack.angle}; + Color tint = tint_get(game, attack); + draw.color = tint; + draw.flash = fill_get(strength_get(attack, attack.tip)); + draw.position = draw.position + jitter_get(draw.flash); + + switch (def.kind) + { + case ATTACK_SWING: + draw.position = attack.origin; + break; + case ATTACK_BOOMERANG: + draw.scale.y = attack.scale; + draw.angle = attack.time * BOOMERANG_SPIN; + break; + case ATTACK_SLASH: + draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); + pose = {&effects, "Slash", draw}; + break; + case ATTACK_ARROW: + { + float fade = std::clamp((attack.duration - attack.time) / ARROW_FADE, 0.0f, 1.0f); + draw.color = color_fade(tint, attack.isAttached ? fade * fade : 1); + pose = {&effects, "Arrow", draw}; + break; + } + case ATTACK_ROCKET: + pose = {&effects, "Rocket", draw}; + break; + case ATTACK_PUNCH: + draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); + pose = {&effects, "Punch", draw}; + break; + case ATTACK_UPPERCUT: + // The fist is drawn rising, so it stands upright when aimed straight ahead and leans with the launch. + // Facing left mirrors it. + draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); + draw.scale = {attack.scale * side, attack.scale}; + draw.angle = attack.angle + PI / 2; + pose = {&effects, "Uppercut", draw, attack.time * effects.fps}; + break; + case ATTACK_BULLET: + pose = {&effects, "Slug", draw}; + break; + case ATTACK_PROJECTILE: + pose = {&effects, "Bullet", draw}; + break; + case ATTACK_HOOK: + pose.animation = "Grapple Tip"; + break; + case ATTACK_MINE: + draw.scale.y = attack.scale; + draw.angle = 0; + if (is_mine_armed(attack) && std::fmod(attack.time, MINE_BLINK) < MINE_BLINK_ON) + draw.flash = 1; + pose = {&effects, "Mine", draw}; + break; + case ATTACK_FLAME: + case ATTACK_FIREBALL: + { + // Flames flicker through their animation, the spread jitter staggering them. Loose flames grow and fade + // out. Fireballs point their tip back along the path. + bool isFlame = def.kind == ATTACK_FLAME; + float size = attack.scale * (isFlame ? FLAME_SHRINK + FLAME_GROW * progress : 1); + draw.scale = {size, size}; + draw.angle = isFlame ? 0 : attack.angle - PI / 2; + if (isFlame) + draw.color = color_fade(tint, std::min(1.0f, (1 - progress) * FLAME_FADE)); + pose = {&effects, "Flame", draw, (attack.time + attack.aimAngle * FLAME_STAGGER) * effects.fps}; + break; + } + case ATTACK_SPIN: + case ATTACK_FLAIL: + pose = {&effects, "Flail Ball", draw}; + break; + case ATTACK_WISP: + { + // Wisps burn like little flames while they hover, tails flickering upward, then streak head-first along + // their path, each a little out of step with the rest. + bool isFlying = attack.velocity.x != 0 || attack.velocity.y != 0; + draw.scale = {attack.scale, attack.scale}; + draw.angle = isFlying ? attack.angle : PI / 2; + pose = {&effects, "Wisp", draw, (attack.time + attack.slot * WISP_FRAME_STAGGER) * effects.fps}; + break; + } + case ATTACK_TELEPORT: + // Drawn as its marker and sparkles instead. + pose.art = nullptr; + break; + default: + break; + } + return pose; +} + +// Draws the selected weapon in the player's hand, aimed at the cursor, unless it is out attacking. It fills in as it +// charges (see charge_fill_get) and shakes harder the further the charge has got. A charging mine weapon holds up the +// mine it's about to place instead. +void Scene::held_draw(Fighter& fighter) +{ + int held = fighter.weapon_key(fighter.slot); + if (held < 0 || (game.sim.weapons.is_busy(fighter, held) && !game.sim.weapons.tether_get(fighter, held, false))) + return; + + float side = fighter.is_left() ? -1 : 1; + Draw draw = { + .color = game.color_get(fighter.index), + .position = fighter.hand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), + .scale = {1, side}, + .angle = vec2_angle(fighter.aim) + kick_get(fighter) * side, + .flash = charge_fill_get(game, fighter), + }; + if (fighter.charge > 0 && WEAPON_DEFS[fighter.weaponLoadout[held]].charged.kind == ATTACK_MINE) + { + draw.scale = {1, 1}; + draw.angle = 0; + game.sprites.effects.draw(game.renderer, "Mine", 0, draw); + return; + } + reload_pose(fighter, held, fighter.cooldowns[held], draw); + hand_draw(fighter, held, false, draw); +} + +// Dual Wield: the off-hand weapon, a darker shade of the fighter's colour, held in the far hand behind it. +void Scene::offhand_draw(Fighter& fighter) +{ + int off = fighter.off_slot_get(); + off = off < 0 ? -1 : fighter.weapon_key(off); + if (!fighter.is_trinket_equipped(TRINKET_DUAL) || off < 0 || + (game.sim.weapons.is_busy(fighter, off) && !game.sim.weapons.tether_get(fighter, off, true))) + return; + + float side = fighter.is_left() ? -1 : 1; + Draw draw = { + .color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE), + .position = fighter.offhand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), + .scale = {1, side}, + .angle = vec2_angle(fighter.aim), + .flash = charge_fill_get(game, fighter), + }; + reload_pose(fighter, off, fighter.cooldowns[off], draw); + hand_draw(fighter, off, true, draw); +} + +// Draws a weapon in a hand. While its whip or hook is out, the hand holds only its base (" Base"), pointing +// down the lash or rope. +void Scene::hand_draw(Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw) +{ + Attack* tether = game.sim.weapons.tether_get(fighter, weaponSlot, isOffhand); + std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name; + if (tether) + { + Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position; + draw.angle = vec2_angle(end - draw.position); + animation += " Base"; + } + // A weapon without held art yet shows its icon, small and upright. + if (!game.sprites.weapons.animation_get(animation.c_str())) + { + draw.scale = {HELD_ICON_SCALE * (draw.scale.y < 0 ? -1 : 1), HELD_ICON_SCALE}; + draw.angle = 0; + game.sprites.icons.draw(game.renderer, animation.c_str(), 0, draw); + return; + } + game.sprites.weapons.draw(game.renderer, animation.c_str(), 0, draw); +} diff --git a/src/scene.h b/src/scene.h new file mode 100644 index 0000000..9d5af2b --- /dev/null +++ b/src/scene.h @@ -0,0 +1,59 @@ +#pragma once + +#include "anm2.h" +#include "sim.h" + +struct Game; + +// Shows the game this screen is on: draws its world, fighters and attacks, animates its fighters, and plays the cues it +// gives as it runs. While muted (replays and games this screen isn't showing), cues pass without a sound or a particle, +// though the menu still follows the match. Each fighter's charging sound is kept here, by fighter. +class Scene : public Cues +{ +public: + bool isMuted = false; + + explicit Scene(Game& game); + void update(); + void draw(); + bool is_flashing(Fighter& fighter); + + void sound_play(SoundId sound) override; + void charge_play(Fighter& fighter) override; + void charge_stop(Fighter& fighter) override; + void burst_spawn(Burst burst, Vec2 position, float angle, int owner) override; + void attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) override; + void shake_add(float amount) override; + void flash_spawn(Vec2 position, float angle, float size, int owner) override; + void fighter_afterimage_spawn(Fighter& fighter) override; + void attack_afterimage_spawn(Attack& attack) override; + void fighter_aura_spawn(Fighter& fighter) override; + void attack_aura_spawn(Attack& attack) override; + void number_spawn(Damage& damage) override; + void level_step(bool isUp) override; + void match_start() override; + void choice_open() override; + void choice_close() override; + void sandbox_return() override; + +private: + Game& game; + int chargeVoices[MAX_FIGHTERS]; + + void world_draw(); + void bot_draw(); + void fighter_draw(Fighter& fighter); + const char* animation_get(Fighter& fighter); + void animator_update(Fighter& fighter, Animator& animator); + Pose fighter_pose_get(Fighter& fighter); + void attacks_draw(); + void magic_draw(); + void tesla_draw(); + void paths_draw(Fighter& fighter); + void collision_draw(); + void blasts_draw(); + Pose attack_pose(Attack& attack); + void held_draw(Fighter& fighter); + void offhand_draw(Fighter& fighter); + void hand_draw(Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw); +}; diff --git a/src/session.cpp b/src/session.cpp index fbd3672..db82e5e 100644 --- a/src/session.cpp +++ b/src/session.cpp @@ -169,11 +169,11 @@ void Session::host_start(Game& game) Fighter own = game.local_get(); for (Device& device : game.input.devices) device.isClaimed = false; - game.fighterCount = 1; - game.fighters[0] = own; - game.fighters[0].index = 0; - game.weapons.attacks.clear(); - Match& match = game.match; + game.sim.fighterCount = 1; + game.sim.fighters[0] = own; + game.sim.fighters[0].index = 0; + game.sim.weapons.attacks.clear(); + Match& match = game.sim.match; std::fill(std::begin(match.owners), std::end(match.owners), -1); match.owners[0] = game.net.selfId; SDL_strlcpy(match.names[0], game.settings.name, sizeof(match.names[0])); @@ -218,20 +218,20 @@ void Session::stop(Game& game) return; isActive = false; int own = -1; - for (int i = 0; i < game.fighterCount; i++) - if (isJoined && game.match.owners[i] == game.net.selfId) + for (int i = 0; i < game.sim.fighterCount; i++) + if (isJoined && game.sim.match.owners[i] == game.net.selfId) own = i; if (own >= 0) - game.fighters[0] = game.fighters[own]; - game.fighters[0].index = 0; - game.fighterCount = 1; - game.weapons.attacks.clear(); - std::fill(std::begin(game.match.owners), std::end(game.match.owners), -1); + game.sim.fighters[0] = game.sim.fighters[own]; + game.sim.fighters[0].index = 0; + game.sim.fighterCount = 1; + game.sim.weapons.attacks.clear(); + std::fill(std::begin(game.sim.match.owners), std::end(game.sim.match.owners), -1); game.local = 0; std::fill(std::begin(game.devices), std::end(game.devices), DEVICE_NONE); game.devices[0] = DEVICE_KEYBOARD; - game.fighters[0].isBot = false; - Match& match = game.match; + game.sim.fighters[0].isBot = false; + Match& match = game.sim.match; std::fill(std::begin(match.queues), std::end(match.queues), Queue{}); std::fill(std::begin(match.isAway), std::end(match.isAway), false); std::fill(std::begin(match.queuedIn), std::end(match.queuedIn), -1); @@ -319,7 +319,7 @@ void Session::update(Game& game, Controls local) else { int end = tick; - game.rollback.snapshot_load(game, snapshots[rollbackTo % SNAPSHOT_RING]); + game.sim.snapshot_load(snapshots[rollbackTo % SNAPSHOT_RING]); arena_restore(game, rollbackTo % SNAPSHOT_RING); tick = rollbackTo; while (tick < end) @@ -371,7 +371,7 @@ void Session::update(Game& game, Controls local) void Session::step(Game& game, bool isLive) { int slot = tick % SNAPSHOT_RING; - snapshots[slot] = game.rollback.snapshot_save(game); + snapshots[slot] = game.sim.snapshot_save(); arena_save(game, slot); snapshotTicks[slot] = tick; checksums[slot] = checksum_full(game); @@ -379,23 +379,23 @@ void Session::step(Game& game, bool isLive) bool isArenaShown = arena.isActive && (isWatching || is_playing_arena(game)); controls_give(game, false); - game.audio.isMuted = game.effects.isMuted = !isLive || isArenaShown; + game.scene.isMuted = !isLive || isArenaShown; game_tick(game); if (arena.isActive) { arena_swap(game); arena_locals_find(game); controls_give(game, true); - game.audio.isMuted = game.effects.isMuted = !isLive || !isArenaShown; + game.scene.isMuted = !isLive || !isArenaShown; game_tick(game); arena_locals_find(game); arena_swap(game); arena_after(game); } - game.audio.isMuted = game.effects.isMuted = false; + game.scene.isMuted = false; if (!arena.isActive) { - int queuer = game.match.queue_startable_get(game); + int queuer = game.sim.match.queue_startable_get(game.sim); if (queuer >= 0) arena_start(game, queuer); } @@ -408,13 +408,13 @@ void Session::step(Game& game, bool isLive) void Session::controls_give(Game& game, bool isArena) { // While the arena's swapped in, the sandbox is what's set aside. - Match& lobby = isArena ? arena.state.match : game.match; - int lobbyCount = isArena ? arena.state.fighterCount : game.fighterCount; - for (int i = 0; i < game.fighterCount; i++) + Match& lobby = isArena ? arena.state.match : game.sim.match; + int lobbyCount = isArena ? arena.state.fighterCount : game.sim.fighterCount; + for (int i = 0; i < game.sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; - int owner = game.match.owners[i]; - bool isIdle = isArena ? owner >= 0 && game.match.isGone[i] : game.match.isAway[i]; + Fighter& fighter = game.sim.fighters[i]; + int owner = game.sim.match.owners[i]; + bool isIdle = isArena ? owner >= 0 && game.sim.match.isGone[i] : game.sim.match.isAway[i]; auto log = logs.find(owner); if (isIdle || owner < 0 || log == logs.end()) { @@ -444,17 +444,17 @@ void Session::controls_give(Game& game, bool isArena) // colour they asked for if it's free; one leaving takes its attacks with it, and the fighters after it move down. void Session::events_apply(Game& game, int at) { - Match& match = game.match; + Match& match = game.sim.match; for (SessionEvent& event : events) { if (event.tick != at) continue; - if (event.type == EVENT_JOIN && game.fighterCount < MAX_FIGHTERS) + if (event.type == EVENT_JOIN && game.sim.fighterCount < MAX_FIGHTERS) { - int index = game.fighterCount++; - Fighter& fighter = game.fighters[index]; + int index = game.sim.fighterCount++; + Fighter& fighter = game.sim.fighters[index]; fighter = Fighter{}; - fighter.init(game, index); + fighter.init(game.sim, index); const Profile& profile = event.profile; fighter.slots_set(std::clamp(profile.weaponSlots, 1, SLOT_MAX), std::clamp(profile.trinketSlots, 1, SLOT_MAX)); @@ -480,33 +480,33 @@ void Session::events_apply(Game& game, int at) } if (event.type == EVENT_LEAVE) { - int index = std::find(match.owners, match.owners + game.fighterCount, event.member) - match.owners; - if (index >= game.fighterCount) + int index = std::find(match.owners, match.owners + game.sim.fighterCount, event.member) - match.owners; + if (index >= game.sim.fighterCount) continue; - std::vector& attacks = game.weapons.attacks; + std::vector& attacks = game.sim.weapons.attacks; std::erase_if(attacks, [&](Attack& attack) { return attack.owner == index; }); for (Attack& attack : attacks) attack.owner -= attack.owner > index; - for (int i = index; i + 1 < game.fighterCount; i++) + for (int i = index; i + 1 < game.sim.fighterCount; i++) { - game.fighters[i] = game.fighters[i + 1]; - game.fighters[i].index = i; - game.bot.states[i] = game.bot.states[i + 1]; + game.sim.fighters[i] = game.sim.fighters[i + 1]; + game.sim.fighters[i].index = i; + game.sim.bot.states[i] = game.sim.bot.states[i + 1]; match.owners[i] = match.owners[i + 1]; match.colors[i] = match.colors[i + 1]; SDL_memcpy(match.names[i], match.names[i + 1], sizeof(match.names[i])); } - match.queue_slots_shift(index, game.fighterCount); - game.fighterCount--; - match.owners[game.fighterCount] = -1; + match.queue_slots_shift(index, game.sim.fighterCount); + game.sim.fighterCount--; + match.owners[game.sim.fighterCount] = -1; } if (event.type == EVENT_QUEUE || event.type == EVENT_UNQUEUE) { - int index = std::find(match.owners, match.owners + game.fighterCount, event.member) - match.owners; - if (index >= game.fighterCount || match.isAway[index]) + int index = std::find(match.owners, match.owners + game.sim.fighterCount, event.member) - match.owners; + if (index >= game.sim.fighterCount || match.isAway[index]) continue; if (event.type == EVENT_QUEUE) - match.queue_open(game, index, event.rules); + match.queue_open(game.sim, index, event.rules); else { match.queues[index].isActive = false; @@ -630,11 +630,11 @@ void Session::packet_handle(Game& game, int from, const std::string& bytes, bool isJoined = true; std::erase_if(events, [&](SessionEvent& event) { return event.tick < at; }); int self = game.net.selfId; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { - int owner = game.match.owners[i]; + int owner = game.sim.match.owners[i]; if (owner >= 0 && owner != self && !names.contains(owner)) - names[owner] = text_clean(game.match.names[i], PLAYER_NAME_MAX); + names[owner] = text_clean(game.sim.match.names[i], PLAYER_NAME_MAX); if (owner < 0 || owner == self) continue; logs[owner].restart(at); @@ -705,12 +705,12 @@ void Session::join_grant(Game& game, int member, const Profile& profile) state = state_full_save(game); else { - Snapshot now = game.rollback.snapshot_save(game); + Snapshot now = game.sim.snapshot_save(); Arena arenaNow = arena; - game.rollback.snapshot_load(game, snapshots[from % SNAPSHOT_RING]); + game.sim.snapshot_load(snapshots[from % SNAPSHOT_RING]); arena_restore(game, from % SNAPSHOT_RING); state = state_full_save(game); - game.rollback.snapshot_load(game, now); + game.sim.snapshot_load(now); arena = arenaNow; } event_add(game, event); @@ -865,9 +865,9 @@ void Session::local_find(Game& game) game.local = 0; for (int i = 0; i < MAX_FIGHTERS; i++) { - bool isOwn = i < game.fighterCount && game.match.owners[i] == game.net.selfId; + bool isOwn = i < game.sim.fighterCount && game.sim.match.owners[i] == game.net.selfId; game.devices[i] = isOwn ? DEVICE_KEYBOARD : DEVICE_NONE; - game.fighters[i].isBot = false; + game.sim.fighters[i].isBot = false; if (isOwn) game.local = i; } @@ -876,8 +876,8 @@ void Session::local_find(Game& game) // Which fighter a member plays, or -1 if they have none (yet). int Session::fighter_get(Game& game, int member) { - for (int i = 0; i < game.fighterCount; i++) - if (game.match.owners[i] == member) + for (int i = 0; i < game.sim.fighterCount; i++) + if (game.sim.match.owners[i] == member) return i; return -1; } @@ -903,8 +903,8 @@ std::string Session::name_get(Game& game, int member) return game.ui.name_get(game, fighter); std::string name = base_name_get(game, member); int same = 1; - for (int i = 0; i < game.fighterCount; i++) - same += game.match.owners[i] >= 0 && base_name_get(game, game.match.owners[i]) == name; + for (int i = 0; i < game.sim.fighterCount; i++) + same += game.sim.match.owners[i] >= 0 && base_name_get(game, game.sim.match.owners[i]) == name; return same > 1 ? name + " (" + std::to_string(same) + ")" : name; } @@ -1054,8 +1054,8 @@ void Session::queue_decide(Game& game, int member, bool isOpen, const Rules& rul // Whether this screen's fighter has a game queued in the sandbox. bool Session::is_queued(Game& game) { - Match& lobby = isSwapped ? arena.state.match : game.match; - int count = isSwapped ? arena.state.fighterCount : game.fighterCount; + Match& lobby = isSwapped ? arena.state.match : game.sim.match; + int count = isSwapped ? arena.state.fighterCount : game.sim.fighterCount; for (int i = 0; i < count; i++) if (lobby.owners[i] == game.net.selfId) return lobby.queues[i].isActive; @@ -1077,8 +1077,8 @@ bool Session::is_playing_arena(Game& game) { if (!arena.isActive) return false; - Match& match = isSwapped ? game.match : arena.state.match; - int count = isSwapped ? game.fighterCount : arena.state.fighterCount; + Match& match = isSwapped ? game.sim.match : arena.state.match; + int count = isSwapped ? game.sim.fighterCount : arena.state.fighterCount; for (int i = 0; i < count; i++) if (match.owners[i] == game.net.selfId && !match.isGone[i]) return true; @@ -1110,8 +1110,8 @@ void Session::arena_leave(Game& game) // The arena's players still in it, by name: "Sneed, Bob and Alice". std::string Session::arena_names_get(Game& game) { - Match& match = isSwapped ? game.match : arena.state.match; - int count = isSwapped ? game.fighterCount : arena.state.fighterCount; + Match& match = isSwapped ? game.sim.match : arena.state.match; + int count = isSwapped ? game.sim.fighterCount : arena.state.fighterCount; std::vector names; for (int i = 0; i < count; i++) if (match.owners[i] >= 0 && !match.isGone[i]) @@ -1126,13 +1126,13 @@ std::string Session::arena_names_get(Game& game) // their map), and which fighter is this screen's. void Session::arena_swap(Game& game) { - std::swap(game.fighters, arena.state.fighters); - std::swap(game.fighterCount, arena.state.fighterCount); - std::swap(game.match, arena.state.match); - std::swap(game.weapons, arena.state.weapons); - std::swap(game.world, arena.state.world); - std::swap(game.rng, arena.state.rng); - std::swap(game.bot, arena.bot); + std::swap(game.sim.fighters, arena.state.fighters); + std::swap(game.sim.fighterCount, arena.state.fighterCount); + std::swap(game.sim.match, arena.state.match); + std::swap(game.sim.weapons, arena.state.weapons); + std::swap(game.sim.world, arena.state.world); + std::swap(game.sim.rng, arena.state.rng); + std::swap(game.sim.bot, arena.bot); std::swap(game.devices, arena.devices); std::swap(game.local, arena.local); } @@ -1143,7 +1143,7 @@ void Session::arena_locals_find(Game& game) game.local = 0; for (int i = 0; i < MAX_FIGHTERS; i++) { - bool isOwn = i < game.fighterCount && game.match.owners[i] == game.net.selfId && !game.match.isGone[i]; + bool isOwn = i < game.sim.fighterCount && game.sim.match.owners[i] == game.net.selfId && !game.sim.match.isGone[i]; game.devices[i] = isOwn ? DEVICE_KEYBOARD : DEVICE_NONE; if (isOwn) game.local = i; @@ -1154,9 +1154,9 @@ void Session::arena_locals_find(Game& game) // chat starts afresh for it. void Session::arena_start(Game& game, int queuer) { - Match& lobby = game.match; + Match& lobby = game.sim.match; int members[MAX_FIGHTERS]; - int count = lobby.queue_members_get(game, queuer, members); + int count = lobby.queue_members_get(game.sim, queuer, members); Rules rules = lobby.queues[queuer].rules; lobby.queues[queuer].isActive = false; arena_build(game, rules, members, count); @@ -1171,7 +1171,7 @@ void Session::arena_start(Game& game, int queuer) // a seed of the sandbox's random generator and the tick, so every machine builds the same one. void Session::arena_build(Game& game, const Rules& rules, const int* members, int count) { - Match& lobby = game.match; + Match& lobby = game.sim.match; int owners[MAX_FIGHTERS]; int colors[MAX_FIGHTERS]; char names[MAX_FIGHTERS][PLAYER_NAME_MAX + 1]; @@ -1185,11 +1185,11 @@ void Session::arena_build(Game& game, const Rules& rules, const int* members, in lobby.isQueueReady[i] = false; lobby.queuedIn[i] = -1; lobby.queues[i].isActive = false; - std::erase_if(game.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); + std::erase_if(game.sim.weapons.attacks, [&](Attack& attack) { return attack.owner == i; }); } - Uint32 seed = game.rng.state ^ (Uint32)tick * ARENA_SEED_MIX; + Uint32 seed = game.sim.rng.state ^ (Uint32)tick * ARENA_SEED_MIX; int players = rules.isBotFill ? std::max(rules.players, count) : count; - int botLevel = game.bot.level; + int botLevel = game.sim.bot.level; arena.isActive = true; arena.serial = tick; @@ -1197,19 +1197,19 @@ void Session::arena_build(Game& game, const Rules& rules, const int* members, in arena.bot = Bot{}; arena.bot.level = botLevel; arena_swap(game); - Match& match = game.match; + Match& match = game.sim.match; match = Match{}; match.rules = rules; match.rules.players = players; - game.rng.state = seed ? seed : RNG_SEED; - game.fighterCount = players; + game.sim.rng.state = seed ? seed : RNG_SEED; + game.sim.fighterCount = players; bool isTaken[COLOR_COUNT] = {}; for (int k = 0; k < count; k++) isTaken[colors[k] % COLOR_COUNT] = true; for (int k = 0; k < players; k++) { - game.fighters[k] = Fighter{}; - game.fighters[k].init(game, k); + game.sim.fighters[k] = Fighter{}; + game.sim.fighters[k].init(game.sim, k); match.owners[k] = k < count ? owners[k] : -1; if (k < count) { @@ -1221,7 +1221,7 @@ void Session::arena_build(Game& game, const Rules& rules, const int* members, in isTaken[match.colors[k]] = true; } arena_locals_find(game); - match.start(game); + match.start(game.sim); arena.graphSerial = arena.serial; arena_locals_find(game); arena_swap(game); @@ -1231,7 +1231,7 @@ void Session::arena_build(Game& game, const Rules& rules, const int* members, in // over; and once a finished match's players all ask to play again, it starts afresh with them. void Session::arena_after(Game& game) { - Match& lobby = game.match; + Match& lobby = game.sim.match; Match& match = arena.state.match; int count = arena.state.fighterCount; int members[MAX_FIGHTERS]; @@ -1280,7 +1280,7 @@ void Session::arena_end(Game& game) { arena.isActive = false; isWatching = false; - std::fill(std::begin(game.match.isAway), std::end(game.match.isAway), false); + std::fill(std::begin(game.sim.match.isAway), std::end(game.sim.match.isAway), false); } // Keeps the arena as it is before a tick, beside the sandbox's snapshot, to rewind to (none if it's not on). @@ -1290,7 +1290,7 @@ void Session::arena_save(Game& game, int slot) if (!arena.isActive) return; arena_swap(game); - arenaSnapshots[slot] = game.rollback.snapshot_save(game); + arenaSnapshots[slot] = game.sim.snapshot_save(); arena_swap(game); } @@ -1303,9 +1303,9 @@ void Session::arena_restore(Game& game, int slot) return; arena.serial = serial; arena_swap(game); - game.rollback.snapshot_load(game, arenaSnapshots[slot]); + game.sim.snapshot_load(arenaSnapshots[slot]); if (arena.graphSerial != serial) - game.bot.graph_build(game.world); + game.sim.bot.graph_build(game.sim.world); arena.graphSerial = serial; arena_locals_find(game); arena_swap(game); @@ -1314,11 +1314,11 @@ void Session::arena_restore(Game& game, int slot) // The checksum of the sandbox and, while it's on, the arena. Uint32 Session::checksum_full(Game& game) { - Uint32 hash = game.rollback.checksum_get(game); + Uint32 hash = game.sim.checksum_get(); if (!arena.isActive) return hash; arena_swap(game); - hash = (hash ^ game.rollback.checksum_get(game)) * ARENA_SEED_MIX + (Uint32)arena.serial; + hash = (hash ^ game.sim.checksum_get()) * ARENA_SEED_MIX + (Uint32)arena.serial; arena_swap(game); return hash; } @@ -1326,7 +1326,7 @@ Uint32 Session::checksum_full(Game& game) // The whole shared state for a newcomer: the sandbox's, then the arena's (after the tick it started) if it's on. std::string Session::state_full_save(Game& game) { - std::string lobby = state_save(game); + std::string lobby = state_save(game.sim); PacketWriter packet; packet.put((Uint32)lobby.size()); packet.bytes += lobby; @@ -1334,7 +1334,7 @@ std::string Session::state_full_save(Game& game) if (!arena.isActive) return packet.bytes; arena_swap(game); - packet.bytes += state_save(game); + packet.bytes += state_save(game.sim); arena_swap(game); return packet.bytes; } @@ -1348,16 +1348,16 @@ bool Session::state_full_load(Game& game, const std::string& bytes) std::string lobby = bytes.substr(reader.at, length); reader.at += length; int serial = -1; - if (!reader.get(serial) || !state_load(game, lobby)) + if (!reader.get(serial) || !state_load(game.sim, lobby)) return false; arena.isActive = serial >= 0; if (!arena.isActive) return true; arena.serial = serial; arena.bot = Bot{}; - arena.bot.level = game.bot.level; + arena.bot.level = game.sim.bot.level; arena_swap(game); - bool isLoaded = state_load(game, bytes.substr(reader.at)); + bool isLoaded = state_load(game.sim, bytes.substr(reader.at)); arena.graphSerial = serial; arena_locals_find(game); arena_swap(game); diff --git a/src/settings.cpp b/src/settings.cpp index 9f30115..b3b9437 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -259,7 +259,7 @@ void Settings::load(Game& game) std::fill(std::begin(sandbox.trinketLoadout), std::end(sandbox.trinketLoadout), -1); std::fill(std::begin(sandbox.weaponSlotLevels), std::end(sandbox.weaponSlotLevels), -1); std::fill(std::begin(sandbox.trinketSlotLevels), std::end(sandbox.trinketSlotLevels), -1); - savedRules = game.match.rules; + savedRules = game.sim.match.rules; char* text = (char*)SDL_LoadFile(file_path_get().c_str(), nullptr); if (!text) return; @@ -304,7 +304,7 @@ void Settings::load(Game& game) weaponSlots = std::clamp(number, 1, SLOT_MAX); if (SDL_strcmp(line, KEY_TRINKET_SLOTS) == 0) trinketSlots = std::clamp(number, 1, SLOT_MAX); - rules_read(game.match.rules, line, value); + rules_read(game.sim.match.rules, line, value); sandbox_read(sandbox, line, value); for (int i = 0; i < CONTROL_COUNT; i++) { @@ -316,7 +316,7 @@ void Settings::load(Game& game) } } SDL_free(text); - savedRules = game.match.rules; + savedRules = game.sim.match.rules; } void Settings::save(Game& game) @@ -336,7 +336,7 @@ void Settings::save(Game& game) length += SDL_snprintf(text + length, sizeof(text) - length, "%s%s=%d,%d\n", KEY_BIND, CONTROL_KEYS[i], binding.key, binding.pad); } - Match& match = game.match; + Match& match = game.sim.match; length += rules_write(match.rules, text + length, sizeof(text) - length); length += sandbox_write(match.state == MATCH_PRACTICE ? game.local_get() : match.sandbox, text + length, sizeof(text) - length); @@ -354,7 +354,7 @@ void Settings::apply(Game& game) fullscreen_apply(game); game.renderer.vsync_set(isVsync); game.audio.gain_set(isMuted ? 0 : volume); - if (game.match.state == MATCH_PRACTICE) + if (game.sim.match.state == MATCH_PRACTICE) { Fighter& fighter = game.local_get(); fighter.slots_set(weaponSlots, trinketSlots); @@ -402,7 +402,7 @@ void Settings::draw(Game& game, Rect area, Page& page) // The sandbox's slots only show in the sandbox. bool isSandbox = false; - if (game.match.state == MATCH_PRACTICE) + if (game.sim.match.state == MATCH_PRACTICE) { if (ui.row_get(page, ROW_HEIGHT, y)) ui.separator_draw(game, {row.x, y, row.w, row.h}, "Sandbox"); @@ -586,7 +586,7 @@ void Settings::defaults_restore(Game& game) fullscreen_apply(game); game.renderer.vsync_set(isVsync); game.audio.gain_set(isMuted ? 0 : volume); - if (game.match.state == MATCH_PRACTICE) + if (game.sim.match.state == MATCH_PRACTICE) game.input.edit_send(game.devices[game.local], edit_encode({.type = EDIT_SLOTS, .index = weaponSlots, .value = trinketSlots})); isDirty = true; @@ -664,7 +664,7 @@ void Settings::restore(Game& game) Vec2 centre = {sandbox.feet.x, sandbox.feet.y - box.h / 2 - 1}; for (float y = -box.h / 2; y <= box.h / 2; y += CLEAR_PROBE) for (float x = -box.w / 2; x <= box.w / 2; x += CLEAR_PROBE) - if (game.world.is_blocking_at({centre.x + x, centre.y + y})) + if (game.sim.world.is_blocking_at({centre.x + x, centre.y + y})) return; fighter.teleport_to(centre); } @@ -673,8 +673,8 @@ void Settings::restore(Game& game) // match starts from it. void Settings::update(Game& game) { - bool isPractice = game.match.state == MATCH_PRACTICE; - isDirty |= !(game.match.rules == savedRules) || (wasPractice && !isPractice); + bool isPractice = game.sim.match.state == MATCH_PRACTICE; + isDirty |= !(game.sim.match.rules == savedRules) || (wasPractice && !isPractice); #ifdef __EMSCRIPTEN__ // A browser tab can close at any moment without a quit, so the sandbox is saved every so often there. Uint64 now = SDL_GetTicks(); diff --git a/src/sim.cmake b/src/sim.cmake new file mode 100644 index 0000000..de84872 --- /dev/null +++ b/src/sim.cmake @@ -0,0 +1,18 @@ +# The simulation: every source that steps a game, free of SDL, shared by the game and the server. Floating point is +# kept strict wherever it's used (see the top CMakeLists), as inline maths in the headers compiles into its users too. +set(SIM_SOURCES + ${CMAKE_CURRENT_LIST_DIR}/bot.cpp + ${CMAKE_CURRENT_LIST_DIR}/fighter.cpp + ${CMAKE_CURRENT_LIST_DIR}/match.cpp + ${CMAKE_CURRENT_LIST_DIR}/sim.cpp + ${CMAKE_CURRENT_LIST_DIR}/state.cpp + ${CMAKE_CURRENT_LIST_DIR}/weapon.cpp + ${CMAKE_CURRENT_LIST_DIR}/world.cpp) +add_library(sim STATIC ${SIM_SOURCES}) +target_include_directories(sim PUBLIC ${CMAKE_CURRENT_LIST_DIR}) +target_compile_features(sim PUBLIC cxx_std_20) +if(MSVC) + target_compile_options(sim PUBLIC /fp:precise /fp:contract-) +else() + target_compile_options(sim PUBLIC -ffp-contract=off -fno-fast-math) +endif() diff --git a/src/sim.cpp b/src/sim.cpp new file mode 100644 index 0000000..b107172 --- /dev/null +++ b/src/sim.cpp @@ -0,0 +1,103 @@ +#include "sim.h" +#include +#include + +constexpr Uint32 FNV_OFFSET = 2166136261u; +constexpr Uint32 FNV_PRIME = 16777619u; + +// Sets up a game from scratch: the sandbox world, the bot graph, fighters and the seeded generator. +void Sim::init(Uint32 seed) +{ + world.sandbox_load(); + bot.init(world); + for (int i = 0; i < MAX_FIGHTERS; i++) + fighters[i].init(*this, i); + rng.state = seed; +} + +void Sim::tick() +{ + match.update(*this); + if (match.state != MATCH_COUNTDOWN) + bot.update(*this); + for (int i = 0; i < fighterCount && match.state != MATCH_COUNTDOWN; i++) + fighters[i].update(*this); + weapons.update(*this); + world.update(*this); +} + +// Moves a level by a step within the levels there are, cueing whether it went up or down. +void level_step(Sim& sim, int& level, int step) +{ + int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); + if (next == level) + return; + sim.cues->level_step(next > level); + level = next; +} + +Snapshot Sim::snapshot_save() +{ + Snapshot snapshot = {.match = match, + .weapons = weapons, + .world = world, + .rng = rng, + .fighterCount = fighterCount}; + std::copy(std::begin(fighters), std::end(fighters), snapshot.fighters); + std::copy(std::begin(bot.states), std::end(bot.states), snapshot.bots); + return snapshot; +} + +void Sim::snapshot_load(Snapshot& snapshot) +{ + std::copy(std::begin(snapshot.fighters), std::end(snapshot.fighters), fighters); + std::copy(std::begin(snapshot.bots), std::end(snapshot.bots), bot.states); + fighterCount = snapshot.fighterCount; + weapons = snapshot.weapons; + world = snapshot.world; + rng = snapshot.rng; + match = snapshot.match; +} + +static void hash_add(Uint32& hash, float value) +{ + Uint32 bits; + std::memcpy(&bits, &value, sizeof(bits)); + hash = (hash ^ bits) * FNV_PRIME; +} + +// A fingerprint of the simulation state that matters for a desync: the fighters, every attack, the dummies, the +// match and the random generator. +Uint32 Sim::checksum_get() +{ + Uint32 hash = FNV_OFFSET; + hash_add(hash, (float)match.state + match.timer); + 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 (float cooldown : fighter.cooldowns) + hash_add(hash, cooldown); + // What the menu's edits change: loadouts, levels, slot counts, stacking and PvP. + for (int slot = 0; slot < SLOT_MAX; slot++) + for (int value : {fighter.weaponLoadout[slot], fighter.trinketLoadout[slot], fighter.weaponSlotLevels[slot], + fighter.trinketSlotLevels[slot]}) + hash_add(hash, (float)value); + for (int level : fighter.weaponItemLevels) + hash_add(hash, (float)level); + for (int level : fighter.trinketItemLevels) + hash_add(hash, (float)level); + for (int value : {fighter.weaponSlots, fighter.trinketSlots, (int)fighter.isPvp}) + hash_add(hash, (float)value); + } + 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}) + hash_add(hash, value); + for (Dummy& dummy : world.dummies) + for (float value : {dummy.flash, dummy.wobble, dummy.wobbleSpeed}) + hash_add(hash, value); + return (hash ^ rng.state) * FNV_PRIME; +} diff --git a/src/sim.h b/src/sim.h new file mode 100644 index 0000000..3c4d4cd --- /dev/null +++ b/src/sim.h @@ -0,0 +1,64 @@ +#pragma once + +#include "bot.h" +#include "match.h" + +// What a game shows and plays as it runs. The simulation calls these as things happen and never reads anything back. +// This base does nothing, for the server, balance runs and replays no one should hear. The client's Scene plays them. +class Cues +{ +public: + virtual ~Cues() = default; + virtual void sound_play(SoundId sound) {} + virtual void charge_play(Fighter& fighter) {} + virtual void charge_stop(Fighter& fighter) {} + virtual void burst_spawn(Burst burst, Vec2 position, float angle, int owner) {} + virtual void attack_burst_spawn(Burst burst, Vec2 position, float angle, Attack& attack) {} + virtual void shake_add(float amount) {} + virtual void flash_spawn(Vec2 position, float angle, float size, int owner) {} + virtual void fighter_afterimage_spawn(Fighter& fighter) {} + virtual void attack_afterimage_spawn(Attack& attack) {} + virtual void fighter_aura_spawn(Fighter& fighter) {} + virtual void attack_aura_spawn(Attack& attack) {} + virtual void number_spawn(Damage& damage) {} + virtual void level_step(bool isUp) {} + virtual void match_start() {} + virtual void choice_open() {} + virtual void choice_close() {} + virtual void sandbox_return() {} +}; + +inline Cues CUES_NONE; + +// Everything a tick reads and writes, so a game can be rewound to it. +struct Snapshot +{ + BotState bots[MAX_FIGHTERS]; + Fighter fighters[MAX_FIGHTERS]; + Match match; + Weapons weapons; + World world; + Rng rng; + int fighterCount = 0; +}; + +// One whole game: everything a tick reads and writes, the bots' map of its world, and where its cues go. +struct Sim +{ + Bot bot; + Fighter fighters[MAX_FIGHTERS]; + Match match; + Weapons weapons; + World world; + Rng rng; + Cues* cues = &CUES_NONE; + int fighterCount = 1; + + void init(Uint32 seed); + void tick(); + Snapshot snapshot_save(); + void snapshot_load(Snapshot& snapshot); + Uint32 checksum_get(); +}; + +void level_step(Sim& sim, int& level, int step); diff --git a/src/sprites.cpp b/src/sprites.cpp index c3e5c1a..8935188 100644 --- a/src/sprites.cpp +++ b/src/sprites.cpp @@ -20,10 +20,3 @@ void Sprites::init(Game& game) for (Animator& animator : fighters) animator.art = &player; } - -// Each tick, every fighter's animator plays whatever its fighter is doing. -void Sprites::update(Game& game) -{ - for (int i = 0; i < game.fighterCount; i++) - game.fighters[i].animator_update(fighters[i]); -} diff --git a/src/sprites.h b/src/sprites.h index f1f0eea..d02027c 100644 --- a/src/sprites.h +++ b/src/sprites.h @@ -28,5 +28,4 @@ public: Animator fighters[MAX_FIGHTERS]; void init(Game& game); - void update(Game& game); }; diff --git a/src/state.cpp b/src/state.cpp index 2fb803f..fb83bdb 100644 --- a/src/state.cpp +++ b/src/state.cpp @@ -1,5 +1,5 @@ #include "state.h" -#include "game.h" +#include "sim.h" #include #include @@ -108,16 +108,16 @@ struct Checker // Both directions walk the same fields in the same order. Weapons' pending attacks aren't among them: they only exist // inside a tick, and the state is taken between ticks. -template static void state_walk(Stream& stream, GameRef& game) +template static void state_walk(Stream& stream, SimRef& sim) { - stream.one(game.fighterCount); - stream.raw(game.fighters, MAX_FIGHTERS); - stream.one(game.match); - stream.one(game.rng); - stream.raw(game.bot.states, MAX_FIGHTERS); - stream.one(game.weapons.orbitClock); - stream.list(game.weapons.attacks); - World& world = game.world; + stream.one(sim.fighterCount); + stream.raw(sim.fighters, MAX_FIGHTERS); + stream.one(sim.match); + stream.one(sim.rng); + stream.raw(sim.bot.states, MAX_FIGHTERS); + stream.one(sim.weapons.orbitClock); + stream.list(sim.weapons.attacks); + World& world = sim.world; stream.list(world.dummies); stream.list(world.obstacles); stream.list(world.ramps); @@ -127,18 +127,18 @@ template static void state_walk(Stream& stream, Ga stream.one(world.width); } -std::string state_save(Game& game) +std::string state_save(Sim& sim) { Writer writer; writer.one(STATE_MAGIC); writer.one(layout_get()); - state_walk(writer, game); + state_walk(writer, sim); return writer.bytes; } // Loads the state if it's whole and from a build that lays things out the same, then rebuilds what's derived from it. // A refused state leaves the game as it was. -bool state_load(Game& game, const std::string& bytes) +bool state_load(Sim& sim, const std::string& bytes) { Reader check = {bytes}; Uint32 magic = 0; @@ -150,13 +150,13 @@ bool state_load(Game& game, const std::string& bytes) // Checked whole first, so a truncated state can't leave the game half loaded. Checker checker = {bytes, check.at}; - state_walk(checker, game); + state_walk(checker, sim); if (checker.isBad || checker.at != bytes.size()) return false; Reader reader = {bytes}; reader.one(magic); reader.one(layout); - state_walk(reader, game); - game.bot.graph_build(game.world); + state_walk(reader, sim); + sim.bot.graph_build(sim.world); return true; } diff --git a/src/state.h b/src/state.h index 7c6bd3b..ce9c439 100644 --- a/src/state.h +++ b/src/state.h @@ -2,12 +2,12 @@ #include -struct Game; +struct Sim; // The whole simulation as bytes, to hand a player joining a game already running. The game state is plain data that // lays out the same on every platform (no pointers or heap inside the structs; see Attack and FixedList), so most of it // goes as it sits in memory, and the few lists as a count and their items. It starts with a layout signature (the sizes // of everything), so a build that lays things out differently refuses it rather than loading garbage. The menu and // everything else this screen alone sees isn't part of it, nor is the bots' map of the world, rebuilt on loading. -std::string state_save(Game& game); -bool state_load(Game& game, const std::string& bytes); +std::string state_save(Sim& sim); +bool state_load(Sim& sim, const std::string& bytes); diff --git a/src/ui.cpp b/src/ui.cpp index 802bd43..e52a942 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -167,12 +167,12 @@ void Ui::init(Game& game) const char* Ui::name_get(Game& game, int fighter) { // Online, a player's name, numbered when someone earlier in the game has the same one ("Fighter", "Fighter (2)"). - if (game.session.isActive && game.match.owners[fighter] >= 0) + if (game.session.isActive && game.sim.match.owners[fighter] >= 0) { - std::string name = game.session.base_name_get(game, game.match.owners[fighter]); + std::string name = game.session.base_name_get(game, game.sim.match.owners[fighter]); int same = 1; for (int i = 0; i < fighter; i++) - same += game.match.owners[i] >= 0 && game.session.base_name_get(game, game.match.owners[i]) == name; + same += game.sim.match.owners[i] >= 0 && game.session.base_name_get(game, game.sim.match.owners[i]) == name; if (same > 1) name += " (" + std::to_string(same) + ")"; SDL_strlcpy(onlineNames[fighter], name.c_str(), sizeof(onlineNames[fighter])); @@ -200,7 +200,7 @@ void Ui::draw(Game& game) game.renderer.camera = {}; offscreen_draw(game, camera); bool isBarsRaised = game.menu.is_bars_open(); - bool isShared = game.match.state != MATCH_PRACTICE && game.local_count() > 1; + bool isShared = game.sim.match.state != MATCH_PRACTICE && game.local_count() > 1; if (!isBarsRaised && !isShared && !isWatching) { hotbar_draw(game); @@ -218,7 +218,7 @@ void Ui::draw(Game& game) } game.chat.draw(game); game.menu.draw(game); - if (game.match.state == MATCH_COUNTDOWN && game.menu.isOpen && !isShared) + if (game.sim.match.state == MATCH_COUNTDOWN && game.menu.isOpen && !isShared) countdown_aside_draw(game, game.input.devices[DEVICE_KEYBOARD].isAiming); if (isBarsRaised) hotbar_draw(game); @@ -229,34 +229,16 @@ void Ui::draw(Game& game) isActiveSeen = false; } -// Moves a level by step within range with a sound. Outside the menu it also pops up above the player. -void Ui::level_step(Game& game, int& level, int step) -{ - int next = std::clamp(level + step, 0, LEVEL_COUNT - 1); - if (next == level) - return; - - bool isUp = next > level; - level = next; - game.audio.play(isUp ? SOUND_LEVEL_UP : SOUND_LEVEL_DOWN); - if (game.menu.isOpen) - return; - - Fighter& fighter = game.local_get(); - Vec2 head = {fighter.center_get().x, fighter.body.box.y}; - game.effects.popup_spawn(head, isUp ? "Level Up" : "Level Down", isUp ? tint : shade); -} - // Stacked above each living fighter: in a deathmatch its health bar, then (in a match, or a lobby's sandbox with others // in it) its name in its colour, then the local fighter's charge bar while charging, which blinks once full. void Ui::bars_draw(Game& game) { - Match& match = game.match; + Match& match = game.sim.match; Renderer& renderer = game.renderer; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = game.sim.fighters[i]; if (!fighter.isAlive) continue; Rect box = fighter.body.box; @@ -274,7 +256,7 @@ void Ui::bars_draw(Game& game) bottom = bar.y - BAR_GAP; } // Names show in a match, and in a lobby's sandbox once there's someone else to tell apart. - bool isNamed = isMatch || (game.session.isActive && game.fighterCount > 1); + bool isNamed = isMatch || (game.session.isActive && game.sim.fighterCount > 1); if (isNamed) { // Online, the player's connection beside it, the two centred together. @@ -293,7 +275,7 @@ void Ui::bars_draw(Game& game) if (i != game.local || fighter.charge < CHARGE_VISIBLE || game.session.is_watching()) continue; float fill = (fighter.charge - CHARGE_VISIBLE) / (1 - CHARGE_VISIBLE); - bool isShown = fighter.charge < 1 || fighter.is_flashing(); + bool isShown = fighter.charge < 1 || game.scene.is_flashing(fighter); Rect bar = {std::round(centerX - CHARGE_BAR_WIDTH / 2), bottom - CHARGE_BAR_HEIGHT, CHARGE_BAR_WIDTH, CHARGE_BAR_HEIGHT}; bar_draw(renderer, bar, isShown ? fill : 0, color); @@ -314,7 +296,7 @@ void Ui::bar_draw(Renderer& renderer, Rect bar, float fill, Color color) // fight, the end of a round and the end of the match. void Ui::match_draw(Game& game) { - Match& match = game.match; + Match& match = game.sim.match; char text[TOOLTIP_LENGTH]; isTipChosen &= match.state == MATCH_COUNTDOWN; Input& input = game.input; @@ -342,14 +324,14 @@ void Ui::match_draw(Game& game) // with it). Renderer& renderer = game.renderer; int length = 0; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) length += SDL_snprintf(text + length, sizeof(text) - length, i > 0 ? " - %d" : "%d", match.wins[i]); Vec2 scale = {SCORE_SCALE, SCORE_SCALE}; float scoreWidth = game.text.size_get(text, FONT_SMALL, SCORE_SCALE).x; float hotbarRight = HOTBAR_MARGIN + game.local_get().weaponSlots * (SLOT_SIZE + SLOT_GAP) - SLOT_GAP; float x = std::round(std::max((CANVAS_WIDTH - scoreWidth) / 2, hotbarRight + SCORE_HOTBAR_GAP)); float center = x + scoreWidth / 2; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { char piece[TOOLTIP_LENGTH]; if (i > 0) @@ -395,7 +377,7 @@ void Ui::match_draw(Game& game) text_center_draw(game, text, y, ROUND_SCALE); y += game.text.line_height_get(FONT_SMALL, ROUND_SCALE) + READY_GAP; SDL_snprintf(text, sizeof(text), isLocalReady ? "%d/%d Ready" : "%d/%d Ready - Press %s to Ready Up", - info.ready, game.fighterCount, info.key); + info.ready, game.sim.fighterCount, info.key); text_center_draw(game, text, y, 1); y += LINE_HEIGHT + READY_GAP; if (game.local_count() > 1 && (match.isWeaponPick || match.isTrinketPick)) @@ -468,7 +450,7 @@ void Ui::match_draw(Game& game) text_center_draw(game, text, y, 1); y += LINE_HEIGHT * 2; } - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { if (match.owners[i] < 0 || match.isGone[i]) continue; @@ -498,17 +480,17 @@ static void time_format(char* text, int size, float seconds) void Ui::race_draw(Game& game) { char text[TOOLTIP_LENGTH]; - time_format(text, sizeof(text), game.match.clock); + time_format(text, sizeof(text), game.sim.match.clock); text_center_draw(game, text, HOTBAR_MARGIN, SCORE_SCALE); float left = std::round((CANVAS_WIDTH - TRACK_WIDTH) / 2); float y = HOTBAR_MARGIN + game.text.line_height_get(FONT_SMALL, SCORE_SCALE) + TRACK_DOT; game.renderer.rect_draw({left, y - TRACK_HEIGHT / 2, TRACK_WIDTH, TRACK_HEIGHT}, shade); - float length = game.world.size_get().x; - for (int i = 0; i < game.fighterCount; i++) + float length = game.sim.world.size_get().x; + for (int i = 0; i < game.sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; - float progress = game.match.finishTimes[i] >= 0 ? 1 : std::clamp(fighter.center_get().x / length, 0.0f, 1.0f); + Fighter& fighter = game.sim.fighters[i]; + float progress = game.sim.match.finishTimes[i] >= 0 ? 1 : std::clamp(fighter.center_get().x / length, 0.0f, 1.0f); float x = std::round(left + progress * TRACK_WIDTH - TRACK_DOT / 2); Rect dot = {x, std::round(y - TRACK_DOT / 2), TRACK_DOT, TRACK_DOT}; game.renderer.rect_draw(dot, game.color_get(i)); @@ -519,16 +501,16 @@ void Ui::race_draw(Game& game) // below the last line. float Ui::results_draw(Game& game, float y) { - Match& match = game.match; + Match& match = game.sim.match; int order[MAX_FIGHTERS]; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) order[i] = i; auto rank = [&](int fighter) { return match.finishTimes[fighter] >= 0 ? match.finishTimes[fighter] : INFINITY; }; - std::stable_sort(order, order + game.fighterCount, [&](int a, int b) { return rank(a) < rank(b); }); + std::stable_sort(order, order + game.sim.fighterCount, [&](int a, int b) { return rank(a) < rank(b); }); - for (int place = 0; place < game.fighterCount; place++) + for (int place = 0; place < game.sim.fighterCount; place++) { int fighter = order[place]; char time[TOOLTIP_LENGTH]; @@ -730,16 +712,16 @@ void Ui::loadout_draw(Game& game, float alpha, float top) // What the countdown shows: the round's name, whole seconds left, how many are ready, and the key that readies up. static CountdownInfo countdown_info_get(Game& game, bool isPad) { - Match& match = game.match; + Match& match = game.sim.match; CountdownInfo info; if (match.rules.mode == MODE_RACE) SDL_strlcpy(info.round, "Race", sizeof(info.round)); - else if (match.is_final_round(game)) + else if (match.is_final_round(game.sim)) SDL_strlcpy(info.round, "Final Round", sizeof(info.round)); else SDL_snprintf(info.round, sizeof(info.round), "Round %d", match.round + 1); info.seconds = (int)std::ceil(match.timer); - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) info.ready += match.isReady[i]; Binding binding = game.input.bindings[CONTROL_READY]; game.input.binding_name_get(isPad ? binding.pad : binding.key, isPad, info.key, sizeof(info.key)); @@ -756,7 +738,7 @@ void Ui::countdown_aside_draw(Game& game, bool isPad) const char* round = info.round; int seconds = info.seconds; int ready = info.ready; - const char* key = game.match.isReady[game.local] ? nullptr : info.key; + const char* key = game.sim.match.isReady[game.local] ? nullptr : info.key; float margin = (CANVAS_WIDTH - MENU_WIDTH) / 2; float middle = MENU_TOP + (HEADER_HEIGHT + BODY_HEIGHT) / 2; auto line_draw = [&](const char* line, float centerX, float y, float scale) { @@ -780,7 +762,7 @@ void Ui::countdown_aside_draw(Game& game, bool isPad) float rightX = CANVAS_WIDTH - margin / 2; y = middle - (key ? LINE_HEIGHT * 3 : LINE_HEIGHT) / 2; - SDL_snprintf(text, sizeof(text), "%d/%d Ready", ready, game.fighterCount); + SDL_snprintf(text, sizeof(text), "%d/%d Ready", ready, game.sim.fighterCount); y += line_draw(text, rightX, y, 1); if (key) { @@ -816,10 +798,10 @@ void Ui::status_draw(Game& game) float y = HOTBAR_MARGIN; float rowHeight = game.text.line_height_get(FONT_SMALL, DPS_SCALE); - if (game.match.state != MATCH_PRACTICE && !game.match.is_racing()) + if (game.sim.match.state != MATCH_PRACTICE && !game.sim.match.is_racing()) { Fighter& fighter = game.local_get(); - float maxHealth = game.match.health_get(); + float maxHealth = game.sim.match.health_get(); Rect bar = {right - STATUS_BAR_WIDTH, std::round(y + (rowHeight - STATUS_BAR_HEIGHT) / 2), STATUS_BAR_WIDTH, STATUS_BAR_HEIGHT}; bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / maxHealth, tint); @@ -1163,7 +1145,7 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o Tooltip tooltip; tooltip.isTrinket = isTrinket; tooltip.item = item; - Fighter& fighter = owner >= 0 ? game.fighters[owner] : game.local_get(); + Fighter& fighter = owner >= 0 ? game.sim.fighters[owner] : game.local_get(); if (isTrinket) { @@ -1176,7 +1158,7 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o SDL_strlcpy(equipped, copies > 0 ? "Equipped" : "Not Equipped", sizeof(equipped)); tooltip.name = def.name; // In the sandbox, the effects show what the next level changes after a ">", as weapons' stats do. - bool isPreview = game.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; + bool isPreview = game.sim.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; char effects[TOOLTIP_LENGTH]; if (isPreview) effects_preview_get(game, def.effects[level], def.effects[level + 1], effects, sizeof(effects)); @@ -1196,7 +1178,7 @@ Tooltip Ui::tooltip_build(Game& game, bool isTrinket, int item, int level, int o const WeaponDef& def = WEAPON_DEFS[item]; tooltip.name = def.name; // In the sandbox, values that change at the next level show it after a ">". - bool isPreview = game.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; + bool isPreview = game.sim.match.state == MATCH_PRACTICE && level < LEVEL_COUNT - 1; SDL_snprintf(tooltip.stats, sizeof(tooltip.stats), isPreview ? "(Level %d, > Level %d)" : "(Level %d)", level + 1, level + 2); stats_table_build(fighter, item, level, isPreview, tooltip); @@ -2015,9 +1997,9 @@ void Ui::icon_draw(Game& game, bool isTrinket, int item, Draw placement, IconSiz void Ui::offscreen_draw(Game& game, Vec2 camera) { Vec2 half = {CANVAS_WIDTH / 2.0f, CANVAS_HEIGHT / 2.0f}; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = game.sim.fighters[i]; Vec2 offset = fighter.center_get() - camera - half; bool isVisible = std::abs(offset.x) < half.x && std::abs(offset.y) < half.y; if (i == game.local || !fighter.isAlive || isVisible) @@ -2048,14 +2030,14 @@ void Ui::crosshairs_draw(Game& game, Vec2 camera) { Input& input = game.input; bool isAny = false; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { if (!game.is_local(i)) continue; isAny = true; bool isMouse = (game.devices[i] == DEVICE_KEYBOARD && !input.devices[DEVICE_KEYBOARD].isAiming) || (i == game.local && game.menu.isOpen); - Vec2 aim = isMouse ? input.mouse : game.fighters[i].controls.cursor - camera; + Vec2 aim = isMouse ? input.mouse : game.sim.fighters[i].controls.cursor - camera; game.sprites.ui.draw(game.renderer, "Crosshair", 0, {.color = game.color_get(i), .position = {std::floor(aim.x), std::floor(aim.y)}}); } @@ -2070,7 +2052,7 @@ void Ui::panels_draw(Game& game) { int players[MAX_FIGHTERS]; int count = 0; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) if (game.is_local(i)) players[count++] = i; float width = (CANVAS_WIDTH - HOTBAR_MARGIN * 2 - LOADOUT_GAP * (count - 1)) / count; @@ -2078,7 +2060,7 @@ void Ui::panels_draw(Game& game) for (int k = 0; k < count; k++) { int index = players[k]; - Fighter& fighter = game.fighters[index]; + Fighter& fighter = game.sim.fighters[index]; Color color = game.color_get(index); Color dim = color_shade(color, SECONDARY_SHADE); Rect panel = {std::round(HOTBAR_MARGIN + k * (width + LOADOUT_GAP)), top, std::floor(width), @@ -2088,11 +2070,11 @@ void Ui::panels_draw(Game& game) float y = panel.y + PICK_PADDING; const char* name = name_get(game, index); game.text.draw(game.renderer, name, {.color = color, .position = {x, y}}); - if (!game.match.is_racing()) + if (!game.sim.match.is_racing()) { float barX = x + game.text.size_get(name).x + PICK_PADDING; Rect bar = {barX, y + 2, panel.x + panel.w - PICK_PADDING - barX, HEALTH_BAR_HEIGHT}; - bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / game.match.health_get(), color); + bar_draw(game.renderer, bar, std::max(0.0f, fighter.health) / game.sim.match.health_get(), color); } int fit = (panel.w - PICK_PADDING * 2 + MINI_STEP - MINI_SLOT) / MINI_STEP; for (int kind = 0; kind < 2; kind++) @@ -2120,19 +2102,19 @@ void Ui::panels_draw(Game& game) // deathmatch, or over the line in a race) and someone else is in it. static bool is_spectating(Game& game) { - MatchState state = (MatchState)game.match.state; + MatchState state = (MatchState)game.sim.match.state; // Watching a queued game from the lobby, it's whoever's still in it. if (game.session.is_watching()) { - for (int i = 0; i < game.fighterCount; i++) - if (game.fighters[i].isAlive) + for (int i = 0; i < game.sim.fighterCount; i++) + if (game.sim.fighters[i].isAlive) return true; return false; } if ((state != MATCH_FIGHT && state != MATCH_ROUND_OVER) || game.local_count() != 1 || game.local_get().isAlive) return false; - for (int i = 0; i < game.fighterCount; i++) - if (i != game.local && game.fighters[i].isAlive) + for (int i = 0; i < game.sim.fighterCount; i++) + if (i != game.local && game.sim.fighters[i].isAlive) return true; return false; } @@ -2162,13 +2144,13 @@ void Ui::spectate_update(Game& game) } auto is_watchable = [&](int fighter) { - return fighter >= 0 && fighter < game.fighterCount && (fighter != game.local || isWatching) && - game.fighters[fighter].isAlive; + return fighter >= 0 && fighter < game.sim.fighterCount && (fighter != game.local || isWatching) && + game.sim.fighters[fighter].isAlive; }; auto next_get = [&](int from, int step) { - for (int k = 1; k <= game.fighterCount; k++) + for (int k = 1; k <= game.sim.fighterCount; k++) { - int fighter = ((from + step * k) % game.fighterCount + game.fighterCount) % game.fighterCount; + int fighter = ((from + step * k) % game.sim.fighterCount + game.sim.fighterCount) % game.sim.fighterCount; if (is_watchable(fighter)) return fighter; } @@ -2176,7 +2158,7 @@ void Ui::spectate_update(Game& game) }; if (!is_watchable(spectated)) { - int taker = spectated >= 0 ? game.fighters[spectated].launcher : game.local_get().launcher; + int taker = spectated >= 0 ? game.sim.fighters[spectated].launcher : game.local_get().launcher; spectated = is_watchable(taker) ? taker : next_get(spectated >= 0 ? spectated : game.local, 1); return; } @@ -2220,13 +2202,13 @@ RivalLayout Ui::rivals_layout(Game& game) RivalLayout layout; Fighter& local = game.local_get(); int slots[2] = {}; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) { if (i == game.local && !game.session.is_watching()) continue; layout.fighters[layout.count++] = i; - slots[0] = std::max(slots[0], game.fighters[i].weaponSlots); - slots[1] = std::max(slots[1], game.fighters[i].trinketSlots); + slots[0] = std::max(slots[0], game.sim.fighters[i].weaponSlots); + slots[1] = std::max(slots[1], game.sim.fighters[i].trinketSlots); } if (layout.count == 0) return layout; @@ -2242,7 +2224,7 @@ RivalLayout Ui::rivals_layout(Game& game) }; float widthMax = icons_width(ICON_FULL, RIVAL_FULL_SLOTS) + inset * 2; char health[TOOLTIP_LENGTH]; - SDL_snprintf(health, sizeof(health), "%d", (int)std::ceil(game.match.health_get())); + SDL_snprintf(health, sizeof(health), "%d", (int)std::ceil(game.sim.match.health_get())); float minimum = game.text.size_get(health, FONT_TINY).x + RIVAL_PADDING + RIVAL_BAR_MIN + inset * 2; float width = 0; for (int size = ICON_FULL; size < ICON_SIZE_COUNT; size++) @@ -2268,7 +2250,7 @@ RivalLayout Ui::rivals_layout(Game& game) } float inner = width - inset * 2; - layout.font = game.match.is_racing() || game.text.size_get(health).x + RIVAL_PADDING + RIVAL_BAR_MIN <= inner + layout.font = game.sim.match.is_racing() || game.text.size_get(health).x + RIVAL_PADDING + RIVAL_BAR_MIN <= inner ? FONT_SMALL : FONT_TINY; for (int k = 0; k < layout.count; k++) @@ -2276,7 +2258,7 @@ RivalLayout Ui::rivals_layout(Game& game) layout.font = FONT_TINY; layout.line = game.text.line_height_get(layout.font); - float healthHeight = game.match.is_racing() ? 0 : layout.line + RIVAL_PADDING; + float healthHeight = game.sim.match.is_racing() ? 0 : layout.line + RIVAL_PADDING; float icons = layout.rows * (ICON_PIXELS[layout.size] + RIVAL_ICON_GAP) - RIVAL_ICON_GAP; float height = inset + layout.line + RIVAL_PADDING + healthHeight + icons + inset; float x = CANVAS_WIDTH - HOTBAR_MARGIN - layout.count * width - (layout.count - 1) * RIVAL_GAP; @@ -2292,7 +2274,7 @@ RivalLayout Ui::rivals_layout(Game& game) // icon shows the item's tooltip, as that fighter has it, in its colour. void Ui::rivals_draw(Game& game) { - Match& match = game.match; + Match& match = game.sim.match; if (match.state == MATCH_PRACTICE || match.state == MATCH_COUNTDOWN) return; Renderer& renderer = game.renderer; @@ -2306,7 +2288,7 @@ void Ui::rivals_draw(Game& game) for (int k = 0; k < layout.count; k++) { int index = layout.fighters[k]; - Fighter& fighter = game.fighters[index]; + Fighter& fighter = game.sim.fighters[index]; Rect card = layout.cards[k]; float idle = fighter.isAlive || match.is_racing() ? HUD_IDLE_ALPHA : RIVAL_OUT_ALPHA; float alpha = index == spectated ? 1 : idle + (1 - idle) * rivalHovers[index]; @@ -2379,7 +2361,7 @@ void Ui::rivals_draw(Game& game) if (hoveredFighter < 0 || game.menu.isOpen) return; - Fighter& fighter = game.fighters[hoveredFighter]; + Fighter& fighter = game.sim.fighters[hoveredFighter]; bool isTrinket = hoveredKind == 1; int item = (isTrinket ? fighter.trinketLoadout : fighter.weaponLoadout)[hoveredSlot]; int level = (isTrinket ? fighter.trinketSlotLevels : fighter.weaponSlotLevels)[hoveredSlot]; @@ -2399,7 +2381,7 @@ void Ui::picks_draw(Game& game, float top) { int players[MAX_FIGHTERS]; int count = 0; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < game.sim.fighterCount; i++) if (game.is_local(i)) players[count++] = i; int columns = std::min(count, 2); @@ -2420,9 +2402,9 @@ void Ui::picks_draw(Game& game, float top) // it, or press a slot's number. void Ui::pick_panel_draw(Game& game, int index, Rect panel) { - Match& match = game.match; + Match& match = game.sim.match; Input& input = game.input; - Fighter& fighter = game.fighters[index]; + Fighter& fighter = game.sim.fighters[index]; int device = game.devices[index]; bool isKeyboard = device == DEVICE_KEYBOARD; bool& isTrinket = isPickTrinkets[index]; @@ -2692,14 +2674,14 @@ void Ui::scroll_mark() void Ui::queues_draw(Game& game) { hoveredQueue = -1; - Match& match = game.match; + Match& match = game.sim.match; if (!game.session.isActive || match.state != MATCH_PRACTICE) return; Renderer& renderer = game.renderer; Input& input = game.input; Vec2 mouse = input.mouse + renderer.camera; float bob = std::round(math_sin(SDL_GetTicks() / 1000.0f * QUEUE_ICON_SPEED) * QUEUE_ICON_BOB); - for (int q = 0; q < game.fighterCount; q++) + for (int q = 0; q < game.sim.fighterCount; q++) { Queue& queue = match.queues[q]; if (!queue.isActive || match.isAway[q]) @@ -2707,7 +2689,7 @@ void Ui::queues_draw(Game& game) Color color = game.color_get(q, true); Rect area = queue.area; int members[MAX_FIGHTERS]; - int count = match.queue_members_get(game, q, members); + int count = match.queue_members_get(game.sim, q, members); renderer.rect_draw(area, color_fade(color, QUEUE_FILL_ALPHA)); Color edge = color_fade(color, QUEUE_EDGE_ALPHA); renderer.rect_draw({area.x, area.y, 1, area.h}, edge); @@ -2720,7 +2702,7 @@ void Ui::queues_draw(Game& game) k < count ? color : color_shade(color, SECONDARY_SHADE)); } - Fighter& queuer = game.fighters[q]; + Fighter& queuer = game.sim.fighters[q]; Rect box = queuer.body.box; Vec2 icon = {std::round(box.x + box.w / 2), std::round(box.y - QUEUE_ICON_RISE + bob)}; float bang = game.text.size_get("!", FONT_SMALL, 2).x; @@ -2763,9 +2745,9 @@ void Ui::queues_draw(Game& game) // The game a hovered queuer has queued, in a tooltip by the mouse. void Ui::queue_tooltip_draw(Game& game) { - if (hoveredQueue < 0 || hoveredQueue >= game.fighterCount || game.menu.isOpen) + if (hoveredQueue < 0 || hoveredQueue >= game.sim.fighterCount || game.menu.isOpen) return; - const Rules& rules = game.match.queues[hoveredQueue].rules; + const Rules& rules = game.sim.match.queues[hoveredQueue].rules; Vec2 size = game_summary_size(game, rules); Vec2 mouse = game.input.mouse; Rect panel = {mouse.x + TOOLTIP_OFFSET, mouse.y + TOOLTIP_OFFSET, size.x + TOOLTIP_PADDING * 2, diff --git a/src/ui.h b/src/ui.h index e1ac55f..cd421d3 100644 --- a/src/ui.h +++ b/src/ui.h @@ -184,7 +184,6 @@ public: void init(Game& game); void update(Game& game); void draw(Game& game); - void level_step(Game& game, int& level, int step); void slot_draw(Game& game, Slot slot); void panel_draw(Renderer& renderer, Rect rect, Color border, float fillAlpha = -1); Tooltip tooltip_build(Game& game, bool isTrinket, int item, int level, int owner = -1); diff --git a/src/weapon.cpp b/src/weapon.cpp index 18d880c..6a36cab 100644 --- a/src/weapon.cpp +++ b/src/weapon.cpp @@ -1,58 +1,33 @@ #include "weapon.h" -#include "game.h" +#include "sim.h" #include constexpr int ATTACKS_MAX = 255; constexpr int DAMAGE_STEP = 5; constexpr int HEAVY_DAMAGE = 40; constexpr int HOOK_SUBSTEPS = 4; -constexpr float BOLT_JITTER = 4; -constexpr float BOLT_SEGMENT = 8; -constexpr float BOLT_WIDTH = 2; constexpr int TESLA_LINKS = 5; constexpr float TESLA_INTERVAL = 0.2f; constexpr float TESLA_RADIUS = 4; constexpr float TESLA_RANGES[LEVEL_COUNT] = {120, 170, 220}; constexpr float DEGREE = PI / 180; -constexpr float ARROW_FADE = 0.6f; constexpr float ARROW_LINGER = 2.5f; constexpr float ARROW_SPAN = 18; constexpr float ARROW_THICKNESS = 2; -constexpr float BEAM_INNER = 4; -constexpr float BEAM_OUTER = 10; constexpr float BEAM_RADIUS = 4; constexpr float BOOMERANG_LIFETIME = 6; constexpr float BOOMERANG_RADIUS = 9; -constexpr float BOOMERANG_SPIN = 20; constexpr float BOOMERANG_STEP = 2; constexpr float BULLET_RADIUS = 4; constexpr float CASING_BACK = 0.4f; constexpr float CAPSULE_STEP = 4; constexpr float CATCH_DISTANCE = 10; -constexpr float CHAIN_SPACING = 8; -constexpr float EXPLOSION_FLASH = 0.05f; -constexpr float EXPLOSION_GROW = 4; -constexpr float EXPLOSION_OUTLINE = 2; constexpr float DOUBLER_DELAY = 0.15f; -constexpr float FALLOFF_CURVE = 1.5f; -constexpr float FALLOFF_DIM = 0.15f; constexpr float FALLOFF_FLOOR = 0.1f; constexpr float FALLOFF_RANGE = 600; -constexpr float FALLOFF_STEP = 6; constexpr float FILL_STRENGTH = 2; -constexpr float FILL_JITTER = 2; -// How hard a charging weapon shakes against an attack filled in as far. -constexpr float CHARGE_SHAKE = 0.5f; -constexpr float FILL_PARTICLES = 60; -constexpr float BLAST_RING_ALPHA = 0.2f; -constexpr float COLLISION_ALPHA = 0.25f; -constexpr float PIP_GAP = 3; -constexpr float PIP_SIZE = 2; -constexpr float PIP_SPIN = 8; -constexpr float TRAJECTORY_ALPHA = 0.12f; constexpr float TRAJECTORY_REACH = 400; constexpr float TRAJECTORY_STEP = 4; -constexpr float TRAJECTORY_TAPER = 0.25f; constexpr float FIRE_SHAKE = 0.5f; constexpr float FIREBALL_RADIUS = 5; constexpr float FLAME_BRAKE = 0.9f; @@ -71,42 +46,28 @@ constexpr float HITSTOP_PER_DAMAGE = 0.002f; constexpr float HITSTOP_MIN_DAMAGE = 10; constexpr float HIT_PARTICLES_PER_DAMAGE = 0.2f; constexpr float FLAME_DRAG = 0.9f; -constexpr float FLAME_FADE = 3; -constexpr float FLAME_STAGGER = 1.12f; -constexpr float FLAME_GROW = 0.8f; constexpr float FLAME_RADIUS = 5; constexpr float FLAME_RISE = 100; -constexpr float FLAME_SHRINK = 0.6f; constexpr float HAMMER_HEAD_RADIUS = 8; constexpr float HAMMER_REACH = 28; constexpr float HAMMER_REBOUND = 380; constexpr float HAMMER_REBOUND_CHARGED = 1.5f; constexpr float HOOK_RADIUS = 6; constexpr float HOOK_WHIFF_TIME = 0.1f; -constexpr float KICK_DECAY = 0.82f; -constexpr float KICK_SWEEP = 0.1f; -constexpr float RELOAD_DROP = 5; -constexpr float RELOAD_PUMP = 4; -constexpr float RELOAD_START = 0.2f; constexpr float KNOCK_LIGHT = 0.3f; constexpr float KNOCK_SLIGHT = 0.1f; constexpr float KNOCKBACK_REACH = 1.5f; constexpr float KNOCKBACK_SPEED = 550; constexpr float LANCE_LENGTH = 40; constexpr float LANCE_RADIUS = 3; -constexpr float MINE_BLINK = 0.6f; -constexpr float MINE_BLINK_ON = 0.1f; constexpr float MUZZLE_DISTANCE = 18; constexpr float POKE_THRUST = 0.35f; constexpr float PROJECTILE_STEP = 8; constexpr float RAY_STEP = 2; -constexpr float RICOCHET_BONUS = 0.5f; constexpr float RECOIL_CURVE = 0.75f; constexpr float ROCKET_LIFETIME = 3; constexpr float ROCKET_RADIUS = 4; constexpr float ROCKET_TAIL = 7; -constexpr float ROPE_INNER = 1; -constexpr float ROPE_OUTER = 3; constexpr float PUNCH_RADIUS = 6; constexpr float PUNCH_THRUST = 0.4f; constexpr float SLASH_DISTANCE = 12; @@ -118,7 +79,6 @@ constexpr float SPEED_DAMAGE = 0.05f; constexpr float PARTIAL_CHARGE_BONUS = 0.5f; constexpr float SPEED_UNIT = 100; constexpr float CONTACT_INTERVAL = 0.4f; -constexpr float WISP_FRAME_STAGGER = 0.3f; constexpr float WISP_GLIDE = 14; constexpr float WISP_HOME_MIN = 10; constexpr float WISP_LAUNCH_STAGGER = 0.06f; @@ -130,11 +90,8 @@ constexpr float WISP_RING_GAP = 10; constexpr float WISP_SPACING = 12; constexpr float WISP_SPIN = 2.5f; constexpr float WISP_TURN = 1.75f; -constexpr float HELD_ICON_SCALE = 0.6f; constexpr float TELEPORT_PROBE = 4; constexpr float TELEPORT_STEP = 2; -constexpr float TELEPORT_RING_ALPHA = 0.35f; -constexpr float TELEPORT_CORE = 0.4f; constexpr float CONTACT_KNOCK = 1; constexpr float EMBIGGEN_DAMAGE = 1 / 3.0f; constexpr float SPLASH_EDGE = 0.25f; @@ -154,17 +111,10 @@ constexpr float WHIP_CONE = 0.25f; constexpr float WHIP_HANDLE = 4; constexpr float WHIP_LOOP = 9; constexpr int WHIP_LOOP_STEPS = 6; -constexpr int WHIP_RAYS = 9; -constexpr float WHIP_UNFURL = 0.6f; -constexpr float GRAPPLE_HANDLE = 7; constexpr float WHIP_PULL = 220; -constexpr float WHIP_JITTER = 0.5f; -constexpr float WHIP_INNER = 2; -constexpr float WHIP_OUTER = 5; constexpr float WOBBLE_IMPULSE = 0.15f; constexpr Vec2 HAMMER_HEAD = {20, 0}; constexpr float SLUG_RADIUS = 2; -constexpr Burst AURA_BURST = {.life = 0.3f, .size = 2, .speed = 40, .spread = 0.3f, .count = 1}; constexpr Burst EMBER_BURST = {.gravity = -150, .life = 0.35f, .size = 2, .speed = 60, .spread = 0.6f, .count = 1}; constexpr Burst CASING_BURST = {.gravity = 700, .life = 0.6f, .size = 1, .speed = 110, .spread = 0.35f, .count = 1}; constexpr Burst BLAST_BURST = {.gravity = 200, .life = 0.5f, .size = 4, .speed = 220, .count = 20}; @@ -190,7 +140,7 @@ static bool is_caught(Fighter& fighter, Attack& attack) // Moves a returning boomerang one tick toward its thrower's hand, sliding along any surface in the way (trying each // axis alone when the straight step is blocked). Reports whether it's done: caught, or wedged with nowhere to go, // which breaks it off with a burst. -static bool boomerang_return(Game& game, Fighter& fighter, Attack& attack) +static bool boomerang_return(Sim& sim, Fighter& fighter, Attack& attack) { Vec2 toHand = fighter.hand_get() - attack.position; float distance = vec2_length(toHand); @@ -206,7 +156,7 @@ static bool boomerang_return(Game& game, Fighter& fighter, Attack& attack) for (Vec2 tried : {step, Vec2{step.x, 0}, Vec2{0, step.y}}) { Vec2 next = attack.position + tried; - if ((tried.x == 0 && tried.y == 0) || game.world.is_blocking_at(next)) + if ((tried.x == 0 && tried.y == 0) || sim.world.is_blocking_at(next)) continue; attack.position = next; isMoved = true; @@ -214,7 +164,7 @@ static bool boomerang_return(Game& game, Fighter& fighter, Attack& attack) } if (isMoved) continue; - game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); return true; } return false; @@ -222,13 +172,13 @@ static bool boomerang_return(Game& game, Fighter& fighter, Attack& attack) // Bounces a direction off the wall between from (open) and to (solid), flipping whichever axis crossed into it, or // mirroring it off a ramp's surface. -static Vec2 reflect_get(Game& game, Vec2 from, Vec2 to, Vec2 direction) +static Vec2 reflect_get(Sim& sim, Vec2 from, Vec2 to, Vec2 direction) { - Vec2 normal = game.world.normal_get(to); + Vec2 normal = sim.world.normal_get(to); if (normal.x != 0 || normal.y != 0) return direction - normal * (2 * vec2_dot(direction, normal)); - bool isWallX = game.world.is_blocking_at({to.x, from.y}); - bool isWallY = game.world.is_blocking_at({from.x, to.y}); + bool isWallX = sim.world.is_blocking_at({to.x, from.y}); + bool isWallY = sim.world.is_blocking_at({from.x, to.y}); if (!isWallX && !isWallY) { isWallX = true; @@ -239,27 +189,27 @@ static Vec2 reflect_get(Game& game, Vec2 from, Vec2 to, Vec2 direction) // Plays the sound of a projectile striking a wall or a target. Hooks latch and rockets and fireballs explode // with their own sounds. -static void impact_play(Game& game, AttackKind kind) +static void impact_play(Sim& sim, AttackKind kind) { switch (kind) { case ATTACK_ARROW: - game.audio.play(SOUND_IMPACT_ARROW); + sim.cues->sound_play(SOUND_IMPACT_ARROW); break; case ATTACK_BOOMERANG: - game.audio.play(SOUND_IMPACT_BOOMERANG); + sim.cues->sound_play(SOUND_IMPACT_BOOMERANG); break; case ATTACK_BULLET: - game.audio.play(SOUND_IMPACT_BULLET); + sim.cues->sound_play(SOUND_IMPACT_BULLET); break; case ATTACK_FLAIL: - game.audio.play(SOUND_FLAIL_LAND); + sim.cues->sound_play(SOUND_FLAIL_LAND); break; case ATTACK_MINE: - game.audio.play(SOUND_IMPACT_MINE); + sim.cues->sound_play(SOUND_IMPACT_MINE); break; case ATTACK_PROJECTILE: - game.audio.play(SOUND_IMPACT_BLASTER); + sim.cues->sound_play(SOUND_IMPACT_BLASTER); break; default: break; @@ -267,14 +217,14 @@ static void impact_play(Game& game, AttackKind kind) } // Turns a projectile off the wall it's about to enter. Each ricochet doubles its later damage. -static void ricochet(Game& game, Attack& attack, Vec2 next) +static void ricochet(Sim& sim, Attack& attack, Vec2 next) { - attack.velocity = reflect_get(game, attack.position, next, attack.velocity); + attack.velocity = reflect_get(sim, attack.position, next, attack.velocity); attack.angle = vec2_angle(attack.velocity); attack.bounces--; attack.ricochets++; - game.audio.play(SOUND_RICOCHET); - game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + sim.cues->sound_play(SOUND_RICOCHET); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } // Walks the capsule in small steps, writing the first point that touches the rect. @@ -304,7 +254,7 @@ static bool is_falloff(AttackKind kind) // Ranged shots lose damage linearly with distance flown: full at the muzzle, down to a floor far out. A beam's // distance runs along it to the point, a hook's is how far it reached from where it was thrown, and a wisp's counts // only from when it set off (not its orbiting). -static float falloff_at(float distance) +float falloff_at(float distance) { return std::max(FALLOFF_FLOOR, 1 - distance / FALLOFF_RANGE); } @@ -324,7 +274,7 @@ static float falloff_get(Attack& attack, Vec2 point) // How far a rocket, mine or fireball's explosion reaches. It grows with trinkets but not with the attack's own size, so // a big fireball keeps its listed area. -static float blast_area_get(Attack& attack) +float blast_area_get(Attack& attack) { return attack.stats.area * attack.scale / attack.stats.scale; } @@ -336,51 +286,17 @@ static float multiplier_get(Attack& attack, Vec2 point) } // How hard an attack hits right now against its plain damage, with its speed bonus, ricochets and falloff. -static float strength_get(Attack& attack, Vec2 point) +float strength_get(Attack& attack, Vec2 point) { return attack.bonus * multiplier_get(attack, point); } // How much of an attack's black fill its colour takes over, from none at plain damage to all at FILL_STRENGTH. -static float fill_get(float strength) +float fill_get(float strength) { return std::clamp((strength - 1) / (FILL_STRENGTH - 1), 0.0f, 1.0f); } -// Sheds particles off the edge of an empowered shape, more the more its colour fills in. They take the shape's colour -// and fill in just as far. -static void aura_spawn(Game& game, Capsule shape, float fill, Color color) -{ - Burst burst = AURA_BURST; - burst.fill = fill; - int count = FILL_PARTICLES * fill * TICK + random_get(0, 1); - for (int i = 0; i < count; i++) - { - float angle = random_get(-PI, PI); - Vec2 point = vec2_lerp(shape.from, shape.to, random_get(0, 1)) + vec2_polar(angle, shape.radius); - game.effects.burst(burst, point, angle, color); - } -} - -// A random offset for an empowered attack or fighter to shake by, wider the more its colour fills in, like a damage -// number. -Vec2 Weapons::jitter_get(float fill) -{ - float reach = FILL_JITTER * fill; - return {random_get(-reach, reach), random_get(-reach, reach)}; -} - -// A colour decayed to match a weakened strength: drained toward grey and darkened, faster than the damage drops so -// weakened shots read clearly. -static Color color_dim(Color color, float strength) -{ - strength = math_pow(std::min(1.0f, strength), FALLOFF_CURVE); - float grey = (color.r + color.g + color.b) / 3; - Color washed = {math_lerp(grey, color.r, strength), math_lerp(grey, color.g, strength), - math_lerp(grey, color.b, strength), color.a}; - return color_shade(washed, FALLOFF_DIM + (1 - FALLOFF_DIM) * strength); -} - // How much of a hit's knockback an attack delivers: rapid shots and beams barely push, arrows and hooks a little, // and melee and explosions in full. static float knock_get(AttackKind kind) @@ -392,72 +308,9 @@ static float knock_get(AttackKind kind) return 1; } -// The owner's colour (darker for secondary attacks), dimming as a shot's damage falls off. -static Color owner_color_get(Game& game, Attack& attack) -{ - return color_shade(game.color_get(attack.owner), attack.isSecondary ? SECONDARY_SHADE : 1); -} - -static Color tint_get(Game& game, Attack& attack) -{ - return color_dim(owner_color_get(game, attack), strength_get(attack, attack.tip)); -} - -// A path drawn as an outlined line: an outer width around a black core of the inner width, kinked by jitter. -struct Stroke -{ - std::vector points; - float outer = 0; - float inner = 0; - float jitter = 0; -}; - -// A beam, rope or whip, each piece coloured by the strength there, dimming along it the way damage falls off. Beams -// start as far along as they've travelled. Jitter kinks it at every piece, moving each frame so it crackles. -static void strength_line_draw(Game& game, Attack& attack, Stroke stroke) -{ - Color color = owner_color_get(game, attack); - float base = attack.bonus * (1 + attack.ricochets * RICOCHET_BONUS); - Vec2 shake = game.weapons.jitter_get(fill_get(base)); - std::vector points; - std::vector distances; - float distance = attack.def()->kind == ATTACK_BEAM ? attack.travelled : 0; - for (size_t i = 0; i + 1 < stroke.points.size(); i++) - { - Vec2 from = stroke.points[i]; - Vec2 delta = stroke.points[i + 1] - from; - Vec2 normal = vec2_normalize({-delta.y, delta.x}); - float length = vec2_length(delta); - int steps = length / FALLOFF_STEP + 1; - for (int step = 0; step < steps; step++) - { - bool isEnd = i == 0 && step == 0; - float nudge = isEnd ? 0 : random_get(-stroke.jitter, stroke.jitter); - points.push_back(from + delta * ((float)step / steps) + normal * nudge + shake); - distances.push_back(distance + length * (step + 0.5f) / steps); - } - distance += length; - } - if (stroke.points.empty()) - return; - points.push_back(stroke.points.back() + shake); - - for (int layer = 0; layer < 2; layer++) - { - for (size_t i = 0; i + 1 < points.size(); i++) - { - float strength = base * falloff_at(distances[i]); - if (layer == 0) - game.renderer.line_draw(points[i], points[i + 1], stroke.outer, color_dim(color, strength)); - else - game.renderer.line_draw(points[i], points[i + 1], stroke.inner, color_shade(color, fill_get(strength))); - } - } -} - // A plain lash rolling out: straight from the handle to its front, round a loop that shrinks as it goes, then the rest // of the whip doubled back above, which starts out behind the fighter. It lies straight once it has cracked. -static std::vector lash_get(Attack& attack) +std::vector lash_get(Attack& attack) { Vec2 direction = vec2_polar(attack.angle, 1); Vec2 up = attack.isLeft ? Vec2{-direction.y, direction.x} : Vec2{direction.y, -direction.x}; @@ -480,29 +333,29 @@ static std::vector lash_get(Attack& attack) // A fighter other than the attacker that's still alive, any dummy, or a block that isn't broken. // Whether one fighter's attacks can touch another: always in a match, but in the sandbox only between two fighters that // both have PvP on. -static bool is_hostile(Game& game, int owner, int target) +static bool is_hostile(Sim& sim, int owner, int target) { - Match& match = game.match; + Match& match = sim.match; return match.state != MATCH_PRACTICE || - (game.fighters[owner].isPvp && game.fighters[target].isPvp && !match.isAway[owner] && !match.isAway[target]); + (sim.fighters[owner].isPvp && sim.fighters[target].isPvp && !match.isAway[owner] && !match.isAway[target]); } -static bool is_target(Game& game, int target, int owner) +static bool is_target(Sim& sim, int target, int owner) { if (target >= MAX_FIGHTERS) { - World& world = game.world; + World& world = sim.world; int index = target - MAX_FIGHTERS; return index < (int)world.dummies.size() && (world.dummies[index].tile < 0 || world.tiles[world.dummies[index].tile] == TILE_BLOCK); } - return target < game.fighterCount && target != owner && game.fighters[target].isAlive && - is_hostile(game, owner, target); + return target < sim.fighterCount && target != owner && sim.fighters[target].isAlive && + is_hostile(sim, owner, target); } -static Rect target_box(Game& game, int target) +static Rect target_box(Sim& sim, int target) { - return target < MAX_FIGHTERS ? game.fighters[target].body.box : game.world.dummies[target - MAX_FIGHTERS].box; + return target < MAX_FIGHTERS ? sim.fighters[target].body.box : sim.world.dummies[target - MAX_FIGHTERS].box; } // Numbers part way from a level's normal attack to its charged one, damage kept to steps of 5. @@ -545,7 +398,7 @@ static Vec2 ray_march(Vec2 start, float angle, float range, auto isBlocked) // The capsules an attack hits with right now, returning how many. A plain whip fans rays across its cone from the // hand, out to its unfurled front and short of walls. Everything else is one capsule from position to tip. -static int shapes_get(Game& game, Attack& attack, Capsule* shapes) +int shapes_get(Sim& sim, Attack& attack, Capsule* shapes) { if (attack.def()->kind != ATTACK_WHIP || attack.isCharged) { @@ -553,7 +406,7 @@ static int shapes_get(Game& game, Attack& attack, Capsule* shapes) return 1; } auto isBlocked = [&](Vec2 point) { - return game.world.is_blocking_at(point); + return sim.world.is_blocking_at(point); }; float front = vec2_length(attack.tip - attack.origin); for (int ray = 0; ray < WHIP_RAYS; ray++) @@ -569,13 +422,13 @@ static int shapes_get(Game& game, Attack& attack, Capsule* shapes) // add a repeat of their own, one after another). With Dual Wield on, the off-hand weapon fires too, for a fraction of // its damage. Every weapon (see Fighter::weapon_key) keeps its own cooldown, in either hand, so swapping to another // weapon never inherits the last one's wait. -void Weapons::fire(Game& game, Fighter& fighter, float charge) +void Weapons::fire(Sim& sim, Fighter& fighter, float charge) { int held = fighter.weapon_key(fighter.slot); float gloves = fighter.trinket_get(TRINKET_GLOVES, 1); if (held >= 0 && fighter.cooldowns[held] <= 0 && !is_busy(fighter, held)) { - bool isSpawned = is_spawned(game, fighter, {.charge = charge, .weaponSlot = held}); + bool isSpawned = is_spawned(sim, fighter, {.charge = charge, .weaponSlot = held}); fighter.cooldowns[held] = fighter.level_get(held).cooldown / gloves; // Each Doubler copy adds an echo, at its own level's power. for (int i = 0, repeat = 0; isSpawned && i < fighter.trinketSlots; i++) @@ -592,7 +445,7 @@ void Weapons::fire(Game& game, Fighter& fighter, float charge) float dual = fighter.trinket_get(TRINKET_DUAL, 0); if (dual > 0 && off >= 0 && off != held && fighter.cooldowns[off] <= 0 && !is_busy(fighter, off)) { - is_spawned(game, fighter, + is_spawned(sim, fighter, {.charge = charge, .power = dual, .weaponSlot = off, .isOffhand = true, .isSecondary = true}); fighter.cooldowns[off] = fighter.level_get(off).cooldown / gloves; } @@ -621,7 +474,7 @@ static bool is_inheriting(AttackKind kind, bool isCharged) } // Whether the fighter's box would fit with its middle at centre, clear of walls (and inside the map). -static bool is_box_clear(Game& game, Fighter& fighter, Vec2 centre) +static bool is_box_clear(Sim& sim, Fighter& fighter, Vec2 centre) { Rect box = fighter.body.box; int columns = (int)(box.w / TELEPORT_PROBE) + 1; @@ -630,7 +483,7 @@ static bool is_box_clear(Game& game, Fighter& fighter, Vec2 centre) for (int column = 0; column <= columns; column++) { Vec2 point = {centre.x - box.w / 2 + box.w * column / columns, centre.y - box.h / 2 + box.h * row / rows}; - if (game.world.is_blocking_at(point)) + if (sim.world.is_blocking_at(point)) return false; } return true; @@ -638,7 +491,7 @@ static bool is_box_clear(Game& game, Fighter& fighter, Vec2 centre) // Where a teleport toward the cursor lands: the cursor, held within range of the fighter's middle, backed off toward // the fighter until its box fits there. Returns the fighter's own middle if nowhere fits. -static Vec2 teleport_spot(Game& game, Fighter& fighter, Vec2 target, float range) +static Vec2 teleport_spot(Sim& sim, Fighter& fighter, Vec2 target, float range) { Vec2 centre = fighter.center_get(); Vec2 offset = target - centre; @@ -647,7 +500,7 @@ static Vec2 teleport_spot(Game& game, Fighter& fighter, Vec2 target, float range for (float along = distance; along > 0; along -= TELEPORT_STEP) { Vec2 spot = centre + direction * along; - if (is_box_clear(game, fighter, spot)) + if (is_box_clear(sim, fighter, spot)) return spot; } return centre; @@ -739,7 +592,7 @@ AttackSummary attack_summary_get(Fighter& fighter, int weapon, int level, bool i // shots, the faster it moves along the aim), and the Speedometer adds to that. Trinkets speed up projectiles and grow // attacks here. Detonating sets off the weapon's mines instead, and mines won't place past the level's count. A level // with a limit fires only as many pieces as fit under it (see LevelDef). -bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) +bool Weapons::is_spawned(Sim& sim, Fighter& fighter, Shot shot) { int key = shot.weaponSlot; int held = fighter.weaponLoadout[key]; @@ -785,10 +638,10 @@ bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) { if (!is_mine_armed(attack) || attack.owner != fighter.index || attack.key != key || attack.isDead) continue; - attack_explode(game, attack); + attack_explode(sim, attack); isLive = true; } - game.audio.play(isLive ? def.sound : SOUND_DETONATE_EMPTY); + sim.cues->sound_play(isLive ? def.sound : SOUND_DETONATE_EMPTY); return true; } @@ -796,7 +649,7 @@ bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) stats.speed *= fighter.trinket_get(TRINKET_ACCELERATOR, 1); float spread = 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0); float jitter = def.spread * DEGREE * spread; - Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + game.rng.random_get(-jitter, jitter), 1); + Vec2 aim = vec2_polar(vec2_angle(fighter.aim) + sim.rng.random_get(-jitter, jitter), 1); float push = is_inheriting(kind, isCharged) ? push_get(fighter, aim) : 0; // A part-charged normal attack (not a drawn one, whose draw already strengthens it) adds to the speed bonus, so it // hits harder and fills in, glows and shakes the same way. @@ -829,7 +682,7 @@ bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) // A teleport is one spell however many copies anything else would make: it marks where it will land. if (kind == ATTACK_TELEPORT) { - attack.position = teleport_spot(game, fighter, fighter.controls.cursor, stats.range); + attack.position = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range); attack.origin = fighter.center_get(); attack.tip = attack.position; attack.hitsLeft = 0; @@ -837,7 +690,7 @@ bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) attack.isCopy = shot.isEcho; attack_remember(attack); attacks.push_back(attack); - game.audio.play(shot.isEcho ? SOUND_ECHO : def.sound); + sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound); return true; } @@ -867,7 +720,7 @@ bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) { float offset = 0; if (stats.volley > 1) - offset += (stats.fan * place + game.rng.random_get(-def.scatter, def.scatter) * DEGREE) * spread; + offset += (stats.fan * place + sim.rng.random_get(-def.scatter, def.scatter) * DEGREE) * spread; if (splits > 1) offset += split_offset(kind, split, splits, spread); @@ -896,21 +749,20 @@ bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) // Until it sets off, its angle is only how far its spread (and any split) turns it off the aim. piece.angle = std::remainder(piece.aimAngle - vec2_angle(fighter.aim), 2 * PI); } - piece_launch(game, piece); + piece_launch(sim, piece); attacks.push_back(piece); } } - game.audio.play(shot.isEcho ? SOUND_ECHO : def.sound); + sim.cues->sound_play(shot.isEcho ? SOUND_ECHO : def.sound); Vec2 muzzle = attack.origin + aim * MUZZLE_DISTANCE; float side = fighter.is_left() ? -1 : 1; if (weapon.flash > 0) - game.effects.flash_spawn(muzzle, vec2_angle(aim), weapon.flash, game.color_get(fighter.index)); + sim.cues->flash_spawn(muzzle, vec2_angle(aim), weapon.flash, fighter.index); if (weapon.isEjecting) - game.effects.burst(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), - game.color_get(fighter.index)); + sim.cues->burst_spawn(CASING_BURST, attack.origin, vec2_angle(aim) - side * (PI / 2 + CASING_BACK), fighter.index); if (isCharged) - game.effects.shake_add(def.shake * FIRE_SHAKE); + sim.cues->shake_add(def.shake * FIRE_SHAKE); // An uppercut launches its fighter the way it rises, cancelling any speed against that first. Shock Absorber // softens every push the shot gives its own fighter: lunges, kickback and Recoil Coil. @@ -929,25 +781,22 @@ bool Weapons::is_spawned(Game& game, Fighter& fighter, Shot shot) return true; } -void Weapons::update(Game& game) +void Weapons::update(Sim& sim) { - for (int i = 0; i < game.fighterCount; i++) - contact_update(game, game.fighters[i]); - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) + contact_update(sim, sim.fighters[i]); + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; if (fighter.hitstop > 0) continue; - Rect box = fighter.body.box; - float radius = box.w / 2; if (fighter.isAlive) - aura_spawn(game, {{box.x + radius, box.y + radius}, {box.x + radius, box.y + box.h - radius}, radius}, - buff_get(fighter), game.color_get(i)); + sim.cues->fighter_aura_spawn(fighter); for (Echo& echo : fighter.echoes) { echo.timer -= TICK; if (echo.timer <= 0 && echo.weaponSlot == fighter.weapon_key(fighter.slot)) - is_spawned(game, fighter, + is_spawned(sim, fighter, { .charge = echo.charge, .power = echo.power, @@ -959,19 +808,19 @@ void Weapons::update(Game& game) fighter.echoes.erase_if([](Echo& echo) { return echo.timer <= 0; }); } - orbits_update(game); + orbits_update(sim); // A fighter's attacks freeze with it in hitstop. for (Attack& attack : attacks) { - if (game.fighters[attack.owner].hitstop > 0) + if (sim.fighters[attack.owner].hitstop > 0) { attack_remember(attack); continue; } bool isNew = attack.time == 0; attack_remember(attack); - attack_move(game, attack); + attack_move(sim, attack); if (is_falloff(attack.def()->kind)) attack.travelled += vec2_length(attack.position - attack.positionPrevious); if (isNew) @@ -981,37 +830,36 @@ void Weapons::update(Game& game) bool isResting = (kind == ATTACK_MINE && attack.time >= attack.duration) || kind == ATTACK_FLAME; bool isLine = kind == ATTACK_BEAM || kind == ATTACK_WHIP; if (!isLine && kind != ATTACK_EXPLOSION && !attack.isAttached && !isResting) - game.effects.afterimage_spawn(attack_pose(game, attack)); + sim.cues->attack_afterimage_spawn(attack); if (!attack.isAttached) - aura_spawn(game, {attack.position, attack.tip, attack.radius}, fill_get(strength_get(attack, attack.tip)), - tint_get(game, attack)); + sim.cues->attack_aura_spawn(attack); // Dummies and blocks are checked before fighters, so racers crowding a block can't soak up every swing at it. Capsule shapes[WHIP_RAYS]; - int shapeCount = shapes_get(game, attack, shapes); - int targets = MAX_FIGHTERS + game.world.dummies.size(); + int shapeCount = shapes_get(sim, attack, shapes); + int targets = MAX_FIGHTERS + sim.world.dummies.size(); for (int i = 0; i < targets; i++) { int target = (i + MAX_FIGHTERS) % targets; - if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || !is_target(game, target, attack.owner)) + if (attack.hitsLeft <= 0 || attack.hitCooldown > 0 || !is_target(sim, target, attack.owner)) continue; for (int shape = 0; shape < shapeCount; shape++) { Vec2 point; - if (!is_capsule_in_rect(shapes[shape], target_box(game, target), point)) + if (!is_capsule_in_rect(shapes[shape], target_box(sim, target), point)) continue; - attack_hit(game, attack, target, point); + attack_hit(sim, attack, target, point); break; } } attack.time += TICK; } - tesla_update(game); + tesla_update(sim); // Standing in your own lingering flames brakes you, launches included. - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; for (Attack& attack : attacks) { bool isNear = vec2_length(attack.position - fighter.center_get()) < FLAME_BRAKE_REACH; @@ -1021,7 +869,7 @@ void Weapons::update(Game& game) break; } } - attacks_cap(game); + attacks_cap(sim); std::erase_if(attacks, [](Attack& attack) { return attack.isDead; }); attacks.insert(attacks.end(), pending.begin(), pending.end()); pending.clear(); @@ -1030,9 +878,9 @@ void Weapons::update(Game& game) // No fighter keeps more than ATTACKS_MAX attacks out: past that, their oldest loose ones (anything not in or tethered // to a hand and not an explosion going off, so mines, stuck arrows, wisps and flames) fizzle out with a puff, oldest // first. Attacks are kept in the order they were made, so the first found are the oldest. -void Weapons::attacks_cap(Game& game) +void Weapons::attacks_cap(Sim& sim) { - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { int count = 0; for (Attack& attack : attacks) @@ -1048,16 +896,16 @@ void Weapons::attacks_cap(Game& game) continue; attack.isDead = true; count--; - game.effects.burst(SMOKE_BURST, attack.position, 0, tint_get(game, attack)); + sim.cues->attack_burst_spawn(SMOKE_BURST, attack.position, 0, attack); } } } // Advances an attack by one tick according to its kind and updates its hit capsule. -void Weapons::attack_move(Game& game, Attack& attack) +void Weapons::attack_move(Sim& sim, Attack& attack) { const AttackDef& def = *attack.def(); - Fighter& fighter = game.fighters[attack.owner]; + Fighter& fighter = sim.fighters[attack.owner]; float progress = attack.time / attack.duration; float range = attack.stats.range; @@ -1076,7 +924,7 @@ void Weapons::attack_move(Game& game, Attack& attack) // A head striking a wall stops the swinger's motion along the aim and boosts them back against it (aiming // down at the floor pops them up), once per swing. - if (!attack.isRebounded && game.world.is_blocking_at(attack.position)) + if (!attack.isRebounded && sim.world.is_blocking_at(attack.position)) { Vec2 away = vec2_polar(attack.aimAngle + PI, 1); float into = vec2_dot(fighter.body.velocity, away); @@ -1084,8 +932,8 @@ void Weapons::attack_move(Game& game, Attack& attack) fighter.impulse_add(away * HAMMER_REBOUND * (attack.isCharged ? HAMMER_REBOUND_CHARGED : 1) * fighter.trinket_get(TRINKET_ABSORBER, 1)); attack.isRebounded = true; - game.audio.play(SOUND_BOUNCE); - game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + sim.cues->sound_play(SOUND_BOUNCE); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } if (progress < 1) break; @@ -1094,9 +942,9 @@ void Weapons::attack_move(Game& game, Attack& attack) if (!attack.isCharged) break; attack.radius = range * SLAM_RATIO; - game.audio.play(SOUND_SLAM); - game.effects.shake_add(def.shake); - game.effects.burst(SLAM_BURST, attack.position, 0, game.color_get(attack.owner)); + sim.cues->sound_play(SOUND_SLAM); + sim.cues->shake_add(def.shake); + sim.cues->burst_spawn(SLAM_BURST, attack.position, 0, attack.owner); break; } case ATTACK_SLASH: @@ -1134,7 +982,7 @@ void Weapons::attack_move(Game& game, Attack& attack) attack.radius = LANCE_RADIUS * attack.scale; attack.isDead = progress >= 1; if (attack.isCharged && progress < POKE_THRUST) - game.effects.burst(TRAIL_BURST, attack.tip, attack.angle + PI, game.color_get(attack.owner)); + sim.cues->burst_spawn(TRAIL_BURST, attack.tip, attack.angle + PI, attack.owner); break; } case ATTACK_ARROW: @@ -1158,34 +1006,34 @@ void Weapons::attack_move(Game& game, Attack& attack) for (int step = 0; step < steps && !attack.isDead; step++) { Vec2 next = attack.position + attack.velocity * (TICK / steps); - bool isWall = game.world.is_blocking_at(next); + bool isWall = sim.world.is_blocking_at(next); if (isWall && attack.bounces > 0) { - ricochet(game, attack, next); + ricochet(sim, attack, next); continue; } // Arrows stick where they land and linger, harmless, as footholds for their archer. Blocks take the hit // instead. - if (isWall && def.kind == ATTACK_ARROW && game.world.tile_at(next) != TILE_BLOCK) + if (isWall && def.kind == ATTACK_ARROW && sim.world.tile_at(next) != TILE_BLOCK) { attack.isAttached = true; attack.hitsLeft = 0; attack.velocity = {}; attack.duration = attack.time + ARROW_LINGER; - impact_play(game, def.kind); - game.effects.burst(IMPACT_BURST, next, 0, game.color_get(attack.owner)); + impact_play(sim, def.kind); + sim.cues->burst_spawn(IMPACT_BURST, next, 0, attack.owner); break; } attack.position = next; - bool isBlocked = game.world.is_blocking_at(attack.position); + bool isBlocked = sim.world.is_blocking_at(attack.position); if (!isBlocked && vec2_length(attack.position - attack.origin) < range) continue; attack.isDead = true; if (isBlocked) - impact_play(game, def.kind); - game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + impact_play(sim, def.kind); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } attack.tip = start; break; @@ -1197,25 +1045,24 @@ void Weapons::attack_move(Game& game, Attack& attack) attack.velocity = vec2_polar(attack.angle, speed); attack.radius = ROCKET_RADIUS * attack.scale; Vec2 next = attack.position + attack.velocity * TICK; - if (game.world.is_blocking_at(next) && attack.bounces > 0) + if (sim.world.is_blocking_at(next) && attack.bounces > 0) { - ricochet(game, attack, next); + ricochet(sim, attack, next); break; } attack.position = next; - game.effects.burst(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL), attack.angle + PI, - game.color_get(attack.owner)); + sim.cues->burst_spawn(SMOKE_BURST, attack.position - vec2_polar(attack.angle, ROCKET_TAIL), attack.angle + PI, attack.owner); bool isSpent = attack.time > ROCKET_LIFETIME || vec2_length(attack.position - attack.origin) > range; - if (game.world.is_blocking_at(attack.position) || isSpent) - attack_explode(game, attack); + if (sim.world.is_blocking_at(attack.position) || isSpent) + attack_explode(sim, attack); break; } case ATTACK_WISP: - wisp_move(game, attack); + wisp_move(sim, attack); break; case ATTACK_TELEPORT: - teleport_move(game, attack); + teleport_move(sim, attack); break; case ATTACK_DETONATE: break; @@ -1254,7 +1101,7 @@ void Weapons::attack_move(Game& game, Attack& attack) break; } auto isBlocked = [&](Vec2 point) { - return game.world.is_blocking_at(point); + return sim.world.is_blocking_at(point); }; float unfurl = std::min(1.0f, progress / WHIP_UNFURL); attack.angle = attack.aimAngle + (attack.isLeft ? -1 : 1) * WHIP_CONE * (2 * unfurl - 1); @@ -1274,7 +1121,7 @@ void Weapons::attack_move(Game& game, Attack& attack) attack.radius = BOOMERANG_RADIUS * attack.scale; if (attack.isReturning) { - attack.isDead = boomerang_return(game, fighter, attack) || attack.time > BOOMERANG_LIFETIME; + attack.isDead = boomerang_return(sim, fighter, attack) || attack.time > BOOMERANG_LIFETIME; break; } if (attack.time >= attack.duration) @@ -1288,14 +1135,14 @@ void Weapons::attack_move(Game& game, Attack& attack) for (int step = 1; step <= steps && !isBlocked; step++) { Vec2 point = vec2_lerp(start, next, (float)step / steps); - isBlocked = game.world.is_blocking_at(point); + isBlocked = sim.world.is_blocking_at(point); if (!isBlocked) attack.position = point; } if (!isBlocked) break; - impact_play(game, def.kind); - game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + impact_play(sim, def.kind); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); attack.duration = attack.time; attack.isReturning = !attack.isCharged; break; @@ -1308,7 +1155,7 @@ void Weapons::attack_move(Game& game, Attack& attack) if (isHeld && attack.time >= attack.duration) { attack.time -= attack.duration; - game.audio.play(def.sound); + sim.cues->sound_play(def.sound); } attack.angle += (attack.isLeft ? -2 : 2) * PI * TICK / attack.duration; attack.position = fighter.center_get() + vec2_polar(attack.angle, range); @@ -1321,7 +1168,7 @@ void Weapons::attack_move(Game& game, Attack& attack) // Slows quickly out of the nozzle and drifts upward, stopping against walls. attack.velocity = attack.velocity * FLAME_DRAG + Vec2{0, -FLAME_RISE * TICK}; Vec2 next = attack.position + attack.velocity * TICK; - if (game.world.is_blocking_at(next)) + if (sim.world.is_blocking_at(next)) attack.velocity = {}; else attack.position = next; @@ -1332,9 +1179,9 @@ void Weapons::attack_move(Game& game, Attack& attack) case ATTACK_FIREBALL: attack.radius = FIREBALL_RADIUS * attack.scale; attack.position = attack.position + attack.velocity * TICK; - game.effects.burst(EMBER_BURST, attack.position, attack.angle + PI, game.color_get(attack.owner)); - if (game.world.is_blocking_at(attack.position) || vec2_length(attack.position - attack.origin) > range) - attack_explode(game, attack); + sim.cues->burst_spawn(EMBER_BURST, attack.position, attack.angle + PI, attack.owner); + if (sim.world.is_blocking_at(attack.position) || vec2_length(attack.position - attack.origin) > range) + attack_explode(sim, attack); break; case ATTACK_MINE: { @@ -1344,10 +1191,10 @@ void Weapons::attack_move(Game& game, Attack& attack) break; float eased = 1 - (1 - progress) * (1 - progress); Vec2 next = attack.origin + vec2_polar(attack.angle, range * eased); - if (game.world.is_blocking_at(next)) + if (sim.world.is_blocking_at(next)) { attack.duration = attack.time; - impact_play(game, def.kind); + impact_play(sim, def.kind); } else attack.position = next; @@ -1358,7 +1205,7 @@ void Weapons::attack_move(Game& game, Attack& attack) if (attack.isReturning) attack.isDead = is_caught(fighter, attack); else - flail_move(game, attack); + flail_move(sim, attack); break; case ATTACK_HOOK: { @@ -1374,8 +1221,8 @@ void Weapons::attack_move(Game& game, Attack& attack) for (int step = 0; step < HOOK_SUBSTEPS && !attack.isAttached; step++) { attack.position = attack.position + attack.velocity * (TICK / HOOK_SUBSTEPS); - if (game.world.is_blocking_at(attack.position)) - hook_attach(game, attack); + if (sim.world.is_blocking_at(attack.position)) + hook_attach(sim, attack); } attack.isDead = !attack.isAttached && vec2_length(attack.position - fighter.hand_get()) > range; break; @@ -1411,21 +1258,21 @@ void Weapons::blend(float alpha) } } -void Weapons::attack_hit(Game& game, Attack& attack, int target, Vec2 point) +void Weapons::attack_hit(Sim& sim, Attack& attack, int target, Vec2 point) { const AttackDef& def = *attack.def(); if (def.kind == ATTACK_ROCKET || def.kind == ATTACK_FIREBALL) { - attack_explode(game, attack); + attack_explode(sim, attack); return; } - impact_play(game, def.kind); + impact_play(sim, def.kind); // Explosions deal full damage at their centre, down to a fraction at the edge, by the target's nearest point. float taper = 1; if (def.kind == ATTACK_EXPLOSION) { - Rect box = target_box(game, target); + Rect box = target_box(sim, target); Vec2 nearest = {std::clamp(attack.position.x, box.x, box.x + box.w), std::clamp(attack.position.y, box.y, box.y + box.h)}; float edge = std::min(1.0f, vec2_length(nearest - attack.position) / attack.stats.range); @@ -1441,13 +1288,13 @@ void Weapons::attack_hit(Game& game, Attack& attack, int target, Vec2 point) def.kind == ATTACK_WISP; attack.isDead |= isShot && attack.hitsLeft <= 0; if (def.kind == ATTACK_HOOK) - hook_attach(game, attack); + hook_attach(sim, attack); // Uppercuts knock the way they rise. - Rect box = target_box(game, target); - Vec2 source = def.kind == ATTACK_EXPLOSION ? attack.position : game.fighters[attack.owner].center_get(); + Rect box = target_box(sim, target); + Vec2 source = def.kind == ATTACK_EXPLOSION ? attack.position : sim.fighters[attack.owner].center_get(); if (def.kind == ATTACK_UPPERCUT) source = Vec2{box.x + box.w / 2, box.y + box.h / 2} - vec2_polar(attack.angle, 1); - damage_deal(game, + damage_deal(sim, { .point = point, .source = source, @@ -1464,57 +1311,56 @@ void Weapons::attack_hit(Game& game, Attack& attack, int target, Vec2 point) // freezes in hitstop, its Boxing Glove adds to the knock, and its Embiggener to the damage (a third of its growth). // Dummies flash and wobble, blocks crack and break, and fighters take the damage themselves. In a race, fighters only // get knocked about, so there's no number. -void Weapons::damage_deal(Game& game, Damage damage) +void Weapons::damage_deal(Sim& sim, Damage damage) { - Fighter& attacker = game.fighters[damage.owner]; - Rect box = target_box(game, damage.target); + Fighter& attacker = sim.fighters[damage.owner]; + Rect box = target_box(sim, damage.target); float embiggen = attacker.trinket_get(TRINKET_EMBIGGENER, 1); damage.amount = std::max(1, (int)std::round(damage.amount * (1 + (embiggen - 1) * EMBIGGEN_DAMAGE))); float side = box.x + box.w / 2 > attacker.center_get().x ? 1 : -1; attacker.hitstop = std::max(attacker.hitstop, damage.hitstop); damage.knock *= attacker.trinket_get(TRINKET_BOXING, 1); if (damage.target < MAX_FIGHTERS) - game.fighters[damage.target].damage_take(game, damage); + sim.fighters[damage.target].damage_take(sim, damage); else { - Dummy& dummy = game.world.dummies[damage.target - MAX_FIGHTERS]; + Dummy& dummy = sim.world.dummies[damage.target - MAX_FIGHTERS]; dummy.flash = 1; dummy.wobbleSpeed += side * WOBBLE_IMPULSE * damage.amount; dummy.health -= damage.amount; if (dummy.tile >= 0 && dummy.health <= 0) - game.world.block_break(game, dummy, damage.owner); + sim.world.block_break(sim, dummy, damage.owner); } Burst burst = HIT_BURST; burst.count += HIT_PARTICLES_PER_DAMAGE * damage.amount; - game.effects.burst(burst, damage.point, side > 0 ? 0 : PI, game.color_get(damage.owner)); - game.effects.shake_add(damage.amount * SHAKE_PER_DAMAGE); - game.audio.play(damage.sound); - if (damage.target < MAX_FIGHTERS && game.match.is_racing()) + sim.cues->burst_spawn(burst, damage.point, side > 0 ? 0 : PI, damage.owner); + sim.cues->shake_add(damage.amount * SHAKE_PER_DAMAGE); + sim.cues->sound_play(damage.sound); + if (damage.target < MAX_FIGHTERS && sim.match.is_racing()) return; - game.effects.number_spawn(damage.point, damage.amount, damage.target, game.color_get(damage.owner)); - game.effects.dps_record(damage.amount); + sim.cues->number_spawn(damage); } // Pebble Helmet: touching another fighter or a dummy (not a block) hurts it, by the level's damage times the usual // bonus for the fighter's speed, at most once every CONTACT_INTERVAL. -void Weapons::contact_update(Game& game, Fighter& fighter) +void Weapons::contact_update(Sim& sim, Fighter& fighter) { float base = fighter.trinket_get(TRINKET_PEBBLE, 0); fighter.contactCooldown -= TICK; if (base <= 0 || !fighter.isAlive || fighter.hitstop > 0 || fighter.contactCooldown > 0) return; Rect box = fighter.body.box; - int targets = MAX_FIGHTERS + game.world.dummies.size(); + int targets = MAX_FIGHTERS + sim.world.dummies.size(); for (int target = 0; target < targets; target++) { bool isFighter = target < MAX_FIGHTERS; - if (isFighter && (target >= game.fighterCount || target == fighter.index || !game.fighters[target].isAlive || - !is_hostile(game, fighter.index, target))) + if (isFighter && (target >= sim.fighterCount || target == fighter.index || !sim.fighters[target].isAlive || + !is_hostile(sim, fighter.index, target))) continue; - if (!isFighter && game.world.dummies[target - MAX_FIGHTERS].tile >= 0) + if (!isFighter && sim.world.dummies[target - MAX_FIGHTERS].tile >= 0) continue; - Rect other = target_box(game, target); + Rect other = target_box(sim, target); if (!is_rect_overlapping(box, other)) continue; float speed = vec2_length(fighter.body.velocity); @@ -1523,7 +1369,7 @@ void Weapons::contact_update(Game& game, Fighter& fighter) Vec2 point = {(std::max(box.x, other.x) + std::min(box.x + box.w, other.x + other.w)) / 2, (std::max(box.y, other.y) + std::min(box.y + box.h, other.y + other.h)) / 2}; damage_deal( - game, { + sim, { .point = point, .source = fighter.center_get(), .hitstop = damage >= HITSTOP_MIN_DAMAGE ? std::min(HITSTOP_MAX, damage * HITSTOP_PER_DAMAGE) : 0, @@ -1541,13 +1387,13 @@ void Weapons::contact_update(Game& game, Fighter& fighter) // Every fighter's hovering wisps share one orbit around it: the oldest fill the inner ring, as many as fit round it // WISP_SPACING apart, and the rest the rings beyond, so a crowd of wisps makes concentric rings. Each ring spreads its // wisps evenly and spins the other way to the one inside it. -void Weapons::orbits_update(Game& game) +void Weapons::orbits_update(Sim& sim) { orbitClock = std::fmod(orbitClock + TICK, 2 * PI / WISP_SPIN); std::vector hovering; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - float scale = game.fighters[i].scale; + float scale = sim.fighters[i].scale; hovering.clear(); for (Attack& attack : attacks) if (attack.owner == i && attack.def()->kind == ATTACK_WISP && !attack.isDead && @@ -1592,9 +1438,9 @@ void Weapons::wisps_release(Fighter& fighter) // moment comes, then set off toward wherever the fighter's cursor is right then (plus their spread) and drift only // very slightly after it (not once they're on top of it), fading out at the end of their range or against a wall (or // ricocheting off it). They hit anything they touch from the start, orbiting included, and drift through platforms. -void Weapons::wisp_move(Game& game, Attack& attack) +void Weapons::wisp_move(Sim& sim, Attack& attack) { - Fighter& fighter = game.fighters[attack.owner]; + Fighter& fighter = sim.fighters[attack.owner]; attack.radius = WISP_RADIUS * attack.scale; if (attack.isGathering || attack.time < attack.duration) { @@ -1610,7 +1456,7 @@ void Weapons::wisp_move(Game& game, Attack& attack) { attack.angle += vec2_angle(toward); attack.velocity = vec2_polar(attack.angle, attack.stats.speed); - game.audio.play(SOUND_WISP); + sim.cues->sound_play(SOUND_WISP); } else if (vec2_length(toward) > WISP_HOME_MIN) { @@ -1620,34 +1466,34 @@ void Weapons::wisp_move(Game& game, Attack& attack) } Vec2 next = attack.position + attack.velocity * TICK; - if (game.world.is_solid_at(next) && attack.bounces > 0) + if (sim.world.is_solid_at(next) && attack.bounces > 0) { - ricochet(game, attack, next); + ricochet(sim, attack, next); return; } attack.length += vec2_length(next - attack.position); attack.position = next; - bool isBlocked = game.world.is_solid_at(attack.position); + bool isBlocked = sim.world.is_solid_at(attack.position); if (!isBlocked && attack.length < attack.stats.range && attack.time < WISP_LIFETIME) return; attack.isDead = true; if (isBlocked) - impact_play(game, ATTACK_PROJECTILE); - game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + impact_play(sim, ATTACK_PROJECTILE); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } // 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. Echoes only make the blast. -void Weapons::teleport_move(Game& game, Attack& attack) +void Weapons::teleport_move(Sim& sim, Attack& attack) { - Fighter& fighter = game.fighters[attack.owner]; + Fighter& fighter = sim.fighters[attack.owner]; attack.radius = 0; attack.hitsLeft = 0; if (attack.time < attack.duration) { if (!attack.isCopy) - game.effects.burst(WARP_BURST, fighter.center_get(), 0, game.color_get(attack.owner)); + sim.cues->burst_spawn(WARP_BURST, fighter.center_get(), 0, attack.owner); return; } @@ -1655,12 +1501,12 @@ void Weapons::teleport_move(Game& game, Attack& attack) Vec2 spot = attack.position; if (!attack.isCopy && !attack.isOffhand && fighter.isAlive) { - game.effects.burst(PORTAL_BURST, fighter.center_get(), 0, game.color_get(attack.owner)); - spot = teleport_spot(game, fighter, attack.position, vec2_length(attack.position - fighter.center_get())); + sim.cues->burst_spawn(PORTAL_BURST, fighter.center_get(), 0, attack.owner); + spot = teleport_spot(sim, fighter, attack.position, vec2_length(attack.position - fighter.center_get())); fighter.teleport_to(spot); - game.effects.shake_add(attack.def()->shake); + sim.cues->shake_add(attack.def()->shake); } - blast_spawn(game, { + blast_spawn(sim, { .position = spot, .area = attack.stats.area * attack.scale, .damage = attack.stats.damage, @@ -1672,28 +1518,10 @@ void Weapons::teleport_move(Game& game, Attack& attack) }); } -// The Grimoire's teleport, drawn rather than sprited: a faint ring where its blast will go with a core swelling inside -// it while it gathers. (Its wisps are drawn with the other attacks.) -void Weapons::magic_draw(Game& game) -{ - for (Attack& attack : attacks) - { - AttackKind kind = attack.def()->kind; - Color color = tint_get(game, attack); - if (kind == ATTACK_TELEPORT && !attack.isDead) - { - float progress = std::min(1.0f, attack.time / attack.duration); - float area = attack.stats.area * attack.scale; - game.renderer.ring_draw(attack.position, area, color_fade(color, TELEPORT_RING_ALPHA)); - game.renderer.circle_draw(attack.position, area * TELEPORT_CORE * progress, color_fade(color, progress)); - } - } -} - // Tesla Coil: a web of lightning between every weapon the fighter has out in the world (anything but explosions). // Each piece arcs to its nearest few neighbours within reach, so everything nearby is connected without the // links growing out of hand for rapid-fire weapons. -std::vector Weapons::chain_get(Game& game, Fighter& fighter) +std::vector Weapons::chain_get(Sim& sim, Fighter& fighter) { std::vector links; if (!fighter.is_trinket_equipped(TRINKET_TESLA)) @@ -1732,25 +1560,25 @@ std::vector Weapons::chain_get(Game& game, Fighter& fighter) } // Shocks every target a fighter's links touch, at most once per interval each. -void Weapons::tesla_update(Game& game) +void Weapons::tesla_update(Sim& sim) { - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; float damage = fighter.trinket_get(TRINKET_TESLA, 0); - for (Capsule& link : chain_get(game, fighter)) + for (Capsule& link : chain_get(sim, fighter)) { - for (int target = 0; target < MAX_FIGHTERS + (int)game.world.dummies.size(); target++) + for (int target = 0; target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++) { Vec2 point; - if (!is_target(game, target, i) || !is_capsule_in_rect(link, target_box(game, target), point)) + if (!is_target(sim, target, i) || !is_capsule_in_rect(link, target_box(sim, target), point)) continue; - float& shock = target < MAX_FIGHTERS ? game.fighters[target].shockCooldown - : game.world.dummies[target - MAX_FIGHTERS].shockCooldown; + float& shock = target < MAX_FIGHTERS ? sim.fighters[target].shockCooldown + : sim.world.dummies[target - MAX_FIGHTERS].shockCooldown; if (shock > 0) continue; shock = TESLA_INTERVAL; - damage_deal(game, {.point = point, + damage_deal(sim, {.point = point, .source = fighter.center_get(), .knock = KNOCK_SLIGHT, .amount = (int)damage, @@ -1762,121 +1590,17 @@ void Weapons::tesla_update(Game& game) } } -// A kinked line from one end of the capsule to the other, its radius as the width. Each kink is nudged sideways -// by up to jitter. -static void bolt_draw(Renderer& renderer, Capsule bolt, float jitter, Color color) +// 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. +std::vector Weapons::paths_get(Sim& sim, Fighter& fighter) { - Vec2 delta = bolt.to - bolt.from; - Vec2 normal = vec2_normalize({-delta.y, delta.x}); - int kinks = vec2_length(delta) / BOLT_SEGMENT + 1; - Vec2 previous = bolt.from; - - for (int k = 1; k <= kinks; k++) - { - float nudge = k == kinks ? 0 : random_get(-jitter, jitter); - Vec2 point = bolt.from + delta * ((float)k / kinks) + normal * nudge; - renderer.line_draw(previous, point, bolt.radius, color); - previous = point; - } -} - -// Each link is a jagged bolt whose kinks move every frame so it crackles. -void Weapons::tesla_draw(Game& game) -{ - for (int i = 0; i < game.fighterCount; i++) - for (Capsule& link : chain_get(game, game.fighters[i])) - bolt_draw(game.renderer, {link.from, link.to, BOLT_WIDTH}, BOLT_JITTER, game.color_get(i)); -} - -void Weapons::draw(Game& game) -{ - Renderer& renderer = game.renderer; - - for (Attack& attack : attacks) - { - Fighter& fighter = game.fighters[attack.owner]; - Color tint = tint_get(game, attack); - AttackKind kind = attack.def()->kind; - if (kind == ATTACK_HOOK) - { - // The rope runs from the end of the grapple's base in the hand. - Vec2 hand = attack.isOffhand ? fighter.offhand_get() : fighter.hand_get(); - Vec2 base = hand + vec2_polar(vec2_angle(attack.position - hand), GRAPPLE_HANDLE); - strength_line_draw(game, attack, {{base, attack.position}, ROPE_OUTER, ROPE_INNER}); - } - - if (kind == ATTACK_SPIN || kind == ATTACK_FLAIL) - { - Vec2 hand = fighter.hand_get(); - Vec2 delta = attack.position - hand; - int links = vec2_length(delta) / CHAIN_SPACING; - for (int i = 1; i < links; i++) - game.sprites.effects.draw(renderer, "Chain", 0, - {.color = tint, - .position = hand + delta * ((float)i / links), - .flash = fill_get(strength_get(attack, attack.tip))}); - } - - float progress = attack.time / attack.duration; - if (kind == ATTACK_BEAM) - { - float width = (1 - progress) * attack.scale; - strength_line_draw(game, attack, {{attack.position, attack.tip}, BEAM_OUTER * width, BEAM_INNER * width}); - } - else if (kind == ATTACK_WHIP) - { - // A plain lash keeps its full width while it unfurls, then thins away after the crack. The tip sprite - // rides the end of the whip, pointing the way it runs. - Vec2 shake = jitter_get(fill_get(strength_get(attack, attack.tip))); - float fade = attack.isCharged ? progress : std::max(0.0f, (progress - WHIP_UNFURL) / (1 - WHIP_UNFURL)); - float width = (1 - fade) * attack.scale; - std::vector points = - attack.isCharged ? std::vector{attack.position, attack.tip} : lash_get(attack); - Vec2 end = points.back(); - Vec2 before = points[points.size() - 2]; - strength_line_draw(game, attack, - {points, WHIP_OUTER * width, WHIP_INNER * width, attack.isCharged ? 0 : WHIP_JITTER}); - game.sprites.effects.draw(renderer, "Whip Tip", 0, - {.color = color_fade(tint, 1 - fade), - .position = end + shake, - .scale = {attack.scale, attack.scale}, - .angle = vec2_angle(end - before)}); - } - else if (kind != ATTACK_EXPLOSION) - attack_pose(game, attack).draw(renderer); - - // A faint ring shows how far anything that explodes would reach, and pips in the shot's colour circle it, one - // for each ricochet it has left. - if (kind == ATTACK_ROCKET || kind == ATTACK_FIREBALL || kind == ATTACK_MINE) - renderer.ring_draw(attack.position, blast_area_get(attack), color_fade(tint, BLAST_RING_ALPHA)); - for (int i = 0; i < attack.bounces && kind != ATTACK_BEAM && !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); - renderer.rect_draw({std::round(pip.x - PIP_SIZE / 2), std::round(pip.y - PIP_SIZE / 2), PIP_SIZE, PIP_SIZE}, - tint); - } - } - magic_draw(game); - tesla_draw(game); -} - -// A very faint line along each path 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. Anything that -// explodes rings the blast it would make where the path ends (its reach counts from the hand). Melee, flames and flails -// are left out, as their paths aren't lines. Every path fades out over the last quarter of its length as drawn, so a -// path cut short by a wall fades too. -void Weapons::trajectory_draw(Game& game, Fighter& fighter) -{ - Color base = game.color_get(fighter.index); - auto faded = [&](float along, float length) { - float fade = std::clamp((length - along) / (length * TRAJECTORY_TAPER), 0.0f, 1.0f); - return color_fade(base, TRAJECTORY_ALPHA * fade); - }; + std::vector paths; int key = fighter.weapon_key(fighter.slot); if (key < 0 || (is_busy(fighter, key) && !tether_get(fighter, key, false))) - return; + return paths; const WeaponDef& weapon = WEAPON_DEFS[fighter.weaponLoadout[key]]; const LevelDef& level = fighter.level_get(key); bool isCharged = fighter.charge >= 1; @@ -1887,30 +1611,28 @@ void Weapons::trajectory_draw(Game& game, Fighter& fighter) stats = stats_blend(level, fighter.charge); if (kind == ATTACK_TELEPORT) { - Vec2 spot = teleport_spot(game, fighter, fighter.controls.cursor, stats.range); - float area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1); + Vec2 spot = teleport_spot(sim, fighter, fighter.controls.cursor, stats.range); Vec2 from = fighter.center_get(); + Path path = {.area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1)}; float length = vec2_length(spot - from); for (float travelled = 0; travelled < length; travelled += TRAJECTORY_STEP) - game.renderer.line_draw(from + (spot - from) * (travelled / length), - from + (spot - from) * (std::min(length, travelled + TRAJECTORY_STEP) / length), 1, - faded(travelled, length)); - game.renderer.ring_draw(spot, area, color_fade(base, BLAST_RING_ALPHA)); - return; + path.steps.push_back(from + (spot - from) * (travelled / length)); + path.steps.push_back(spot); + paths.push_back(path); + return paths; } bool isArrow = kind == ATTACK_ARROW; 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 && !isCharged); if (!isArrow && !isStraight) - return; + 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. int splits = fighter.trinket_get(TRINKET_SPLITTER, 1); float area = stats.area * def.scale * fighter.trinket_get(TRINKET_ENLARGER, 1) * (splits > 1 ? SPLIT_SCALE : 1); float reach = std::min(stats.range, TRAJECTORY_REACH); - std::vector steps; for (int split = 0; split < splits; split++) { float offset = splits > 1 ? split_offset(kind, split, splits, 1 - fighter.trinket_get(TRINKET_DIAZEPAM, 0)) : 0; @@ -1919,247 +1641,63 @@ void Weapons::trajectory_draw(Game& game, Fighter& fighter) Vec2 velocity = isArrow ? aim * speed : aim; Vec2 point = fighter.hand_get() + aim * MUZZLE_DISTANCE; int bounces = is_bouncy(kind) ? (int)fighter.trinket_get(TRINKET_RICOCHET, 0) : 0; - steps.clear(); + Path path; bool isEnded = stats.range <= TRAJECTORY_REACH; for (float travelled = area > 0 ? MUZZLE_DISTANCE : 0; travelled < reach; travelled += TRAJECTORY_STEP) { float time = TRAJECTORY_STEP / std::max(1.0f, vec2_length(velocity)); Vec2 next = point + velocity * time; - if (game.world.is_blocking_at(next)) + if (sim.world.is_blocking_at(next)) { isEnded |= bounces <= 0; if (bounces-- <= 0) break; - velocity = reflect_get(game, point, next, velocity); + velocity = reflect_get(sim, point, next, velocity); continue; } velocity.y += isArrow ? ARROW_GRAVITY * time : 0; - steps.push_back(point); + path.steps.push_back(point); point = next; } - steps.push_back(point); - for (int i = 0; i + 1 < (int)steps.size(); i++) - game.renderer.line_draw(steps[i], steps[i + 1], 1, faded(i, steps.size() - 1)); - if (area > 0 && isEnded) - game.renderer.ring_draw(point, area, color_fade(base, BLAST_RING_ALPHA)); + path.steps.push_back(point); + path.area = isEnded ? area : 0; + paths.push_back(path); } -} - -// Fills every pixel inside any of the capsules, a row of runs at a time, so where they overlap stays even. -static void capsules_fill(Renderer& renderer, Capsule* shapes, int count, Color color) -{ - Rect bounds = {INFINITY, INFINITY, -INFINITY, -INFINITY}; - for (int i = 0; i < count; i++) - { - Capsule& shape = shapes[i]; - bounds.x = std::min({bounds.x, shape.from.x - shape.radius, shape.to.x - shape.radius}); - bounds.y = std::min({bounds.y, shape.from.y - shape.radius, shape.to.y - shape.radius}); - bounds.w = std::max({bounds.w, shape.from.x + shape.radius, shape.to.x + shape.radius}); - bounds.h = std::max({bounds.h, shape.from.y + shape.radius, shape.to.y + shape.radius}); - } - auto isInside = [&](Vec2 point) { - for (int i = 0; i < count; i++) - { - Vec2 line = shapes[i].to - shapes[i].from; - float length = vec2_dot(line, line); - float t = length > 0 ? std::clamp(vec2_dot(point - shapes[i].from, line) / length, 0.0f, 1.0f) : 0; - if (vec2_length(point - (shapes[i].from + line * t)) <= shapes[i].radius) - return true; - } - return false; - }; - for (float y = std::floor(bounds.y); y <= bounds.h; y++) - { - float start = -INFINITY; - for (float x = std::floor(bounds.x); x <= bounds.w + 1; x++) - { - bool isIn = x <= bounds.w && isInside({x + 0.5f, y + 0.5f}); - if (isIn && start == -INFINITY) - start = x; - if (!isIn && start != -INFINITY) - { - renderer.rect_draw({start, y, x - start, 1}, color); - start = -INFINITY; - } - } - } -} - -// The Display Collision setting: every attack's hit shapes as the hit test sees them, and the boxes fighters and -// dummies are hit in, see-through in their owner's colour. -void Weapons::collision_draw(Game& game) -{ - Renderer& renderer = game.renderer; - for (Attack& attack : attacks) - { - Capsule shapes[WHIP_RAYS]; - int count = shapes_get(game, attack, shapes); - capsules_fill(renderer, shapes, count, color_fade(owner_color_get(game, attack), COLLISION_ALPHA)); - } - for (int i = 0; i < game.fighterCount; i++) - if (game.fighters[i].isAlive) - renderer.rect_draw(game.fighters[i].body.box, color_fade(game.color_get(i), COLLISION_ALPHA)); - for (Dummy& dummy : game.world.dummies) - if (dummy.tile < 0) - renderer.rect_draw(dummy.box, color_fade(WHITE, COLLISION_ALPHA)); -} - -// Explosions draw before the player, so their filled centre never hides anyone standing in the blast. -void Weapons::blast_draw(Game& game) -{ - for (Attack& attack : attacks) - { - if (attack.def()->kind != ATTACK_EXPLOSION) - continue; - float progress = attack.time / attack.duration; - float radius = attack.stats.range * std::min(1.0f, progress * EXPLOSION_GROW); - Color color = color_dim(owner_color_get(game, attack), attack.bonus); - float fill = fill_get(attack.bonus); - Vec2 center = attack.position + jitter_get(fill); - game.renderer.circle_draw(center, radius, color_fade(color, 1 - progress)); - if (attack.time > EXPLOSION_FLASH) - game.renderer.circle_draw(center, radius - EXPLOSION_OUTLINE, - color_fade(color_shade(color, fill), 1 - progress)); - } -} - -// How a sprite-based attack looks right now: its weapon, or the projectile or effect it fired. Shared by the live -// attack and its afterimages. -Pose Weapons::attack_pose(Game& game, Attack& attack) -{ - const AttackDef& def = *attack.def(); - float progress = attack.time / attack.duration; - float side = attack.isLeft ? -1 : 1; - Anm2& effects = game.sprites.effects; - Pose pose = {.art = &game.sprites.weapons, .animation = WEAPON_DEFS[attack.weapon].name}; - Draw& draw = pose.placement; - draw = {.position = attack.position, .scale = {attack.scale, attack.scale * side}, .angle = attack.angle}; - Color tint = tint_get(game, attack); - draw.color = tint; - draw.flash = fill_get(strength_get(attack, attack.tip)); - draw.position = draw.position + jitter_get(draw.flash); - - switch (def.kind) - { - case ATTACK_SWING: - draw.position = attack.origin; - break; - case ATTACK_BOOMERANG: - draw.scale.y = attack.scale; - draw.angle = attack.time * BOOMERANG_SPIN; - break; - case ATTACK_SLASH: - draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); - pose = {&effects, "Slash", draw}; - break; - case ATTACK_ARROW: - { - float fade = std::clamp((attack.duration - attack.time) / ARROW_FADE, 0.0f, 1.0f); - draw.color = color_fade(tint, attack.isAttached ? fade * fade : 1); - pose = {&effects, "Arrow", draw}; - break; - } - case ATTACK_ROCKET: - pose = {&effects, "Rocket", draw}; - break; - case ATTACK_PUNCH: - draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); - pose = {&effects, "Punch", draw}; - break; - case ATTACK_UPPERCUT: - // The fist is drawn rising, so it stands upright when aimed straight ahead and leans with the launch. - // Facing left mirrors it. - draw.color = color_fade(tint, std::min(1.0f, 2 * (1 - progress))); - draw.scale = {attack.scale * side, attack.scale}; - draw.angle = attack.angle + PI / 2; - pose = {&effects, "Uppercut", draw, attack.time * effects.fps}; - break; - case ATTACK_BULLET: - pose = {&effects, "Slug", draw}; - break; - case ATTACK_PROJECTILE: - pose = {&effects, "Bullet", draw}; - break; - case ATTACK_HOOK: - pose.animation = "Grapple Tip"; - break; - case ATTACK_MINE: - draw.scale.y = attack.scale; - draw.angle = 0; - if (is_mine_armed(attack) && std::fmod(attack.time, MINE_BLINK) < MINE_BLINK_ON) - draw.flash = 1; - pose = {&effects, "Mine", draw}; - break; - case ATTACK_FLAME: - case ATTACK_FIREBALL: - { - // Flames flicker through their animation, the spread jitter staggering them. Loose flames grow and fade - // out. Fireballs point their tip back along the path. - bool isFlame = def.kind == ATTACK_FLAME; - float size = attack.scale * (isFlame ? FLAME_SHRINK + FLAME_GROW * progress : 1); - draw.scale = {size, size}; - draw.angle = isFlame ? 0 : attack.angle - PI / 2; - if (isFlame) - draw.color = color_fade(tint, std::min(1.0f, (1 - progress) * FLAME_FADE)); - pose = {&effects, "Flame", draw, (attack.time + attack.aimAngle * FLAME_STAGGER) * effects.fps}; - break; - } - case ATTACK_SPIN: - case ATTACK_FLAIL: - pose = {&effects, "Flail Ball", draw}; - break; - case ATTACK_WISP: - { - // Wisps burn like little flames while they hover, tails flickering upward, then streak head-first along - // their path, each a little out of step with the rest. - bool isFlying = attack.velocity.x != 0 || attack.velocity.y != 0; - draw.scale = {attack.scale, attack.scale}; - draw.angle = isFlying ? attack.angle : PI / 2; - pose = {&effects, "Wisp", draw, (attack.time + attack.slot * WISP_FRAME_STAGGER) * effects.fps}; - break; - } - case ATTACK_TELEPORT: - // Drawn as its marker and sparkles instead. - pose.art = nullptr; - break; - default: - break; - } - return pose; + return paths; } // Latches the hook where it is and sets the rope to its fighter's current distance. Charged hooks start pulling. -void Weapons::hook_attach(Game& game, Attack& attack) +void Weapons::hook_attach(Sim& sim, Attack& attack) { - Fighter& fighter = game.fighters[attack.owner]; + Fighter& fighter = sim.fighters[attack.owner]; attack.isAttached = true; attack.isPulling = attack.isCharged; attack.hitsLeft = 0; attack.length = std::min(vec2_length(fighter.center_get() - attack.position), attack.stats.range); - game.audio.play(SOUND_LATCH); - game.effects.shake_add(attack.def()->shake); - game.effects.burst(IMPACT_BURST, attack.position, 0, game.color_get(attack.owner)); + sim.cues->sound_play(SOUND_LATCH); + sim.cues->shake_add(attack.def()->shake); + sim.cues->burst_spawn(IMPACT_BURST, attack.position, 0, attack.owner); } // Traces instant attacks for one piece: beams march to the first wall and reflect off any Ricochet bounces, // whips lash from the hand to the first wall (pulling their wielder toward it, and charged ones blowing up at the // tip), and charged hooks plunge into // the first grabbable surface or target (dummy or enemy fighter). -void Weapons::piece_launch(Game& game, Attack& piece) +void Weapons::piece_launch(Sim& sim, Attack& piece) { if (piece.def()->kind == ATTACK_WHIP) { auto isBlocked = [&](Vec2 point) { - return game.world.is_blocking_at(point); + return sim.world.is_blocking_at(point); }; piece.position = piece.origin; piece.tip = ray_march(piece.origin, piece.angle, piece.stats.range, isBlocked); Vec2 lash = piece.tip - piece.origin; if (!piece.isCopy && vec2_length(lash) < piece.stats.range - RAY_STEP) - game.fighters[piece.owner].impulse_add(vec2_normalize(lash) * WHIP_PULL * - game.fighters[piece.owner].trinket_get(TRINKET_ABSORBER, 1)); + sim.fighters[piece.owner].impulse_add(vec2_normalize(lash) * WHIP_PULL * + sim.fighters[piece.owner].trinket_get(TRINKET_ABSORBER, 1)); if (piece.isCharged) - blast_spawn(game, { + blast_spawn(sim, { .position = piece.tip, .area = piece.stats.area * piece.scale / piece.stats.scale, .damage = piece.stats.damage, @@ -2173,47 +1711,47 @@ void Weapons::piece_launch(Game& game, Attack& piece) if (piece.def()->kind == ATTACK_BEAM) { auto isBlocked = [&](Vec2 point) { - return game.world.is_blocking_at(point); + return sim.world.is_blocking_at(point); }; piece.tip = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); - game.effects.burst(SPARK_BURST, piece.tip, piece.angle + PI, game.color_get(piece.owner)); + sim.cues->burst_spawn(SPARK_BURST, piece.tip, piece.angle + PI, piece.owner); Attack segment = piece; for (int i = 0; i < piece.bounces; i++) { segment.travelled += vec2_length(segment.tip - segment.position); Vec2 before = segment.tip - vec2_polar(segment.angle, RAY_STEP); - segment.angle = vec2_angle(reflect_get(game, before, segment.tip, vec2_polar(segment.angle, 1))); + 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); - game.effects.burst(SPARK_BURST, segment.tip, segment.angle + PI, game.color_get(piece.owner)); + sim.cues->burst_spawn(SPARK_BURST, segment.tip, segment.angle + PI, piece.owner); } } if (piece.def()->kind == ATTACK_HOOK && piece.isCharged) { auto isBlocked = [&](Vec2 point) { - for (int target = 0; target < MAX_FIGHTERS + (int)game.world.dummies.size(); target++) - if (is_target(game, target, piece.owner) && is_point_in_rect(point, target_box(game, target))) + for (int target = 0; target < MAX_FIGHTERS + (int)sim.world.dummies.size(); target++) + if (is_target(sim, target, piece.owner) && is_point_in_rect(point, target_box(sim, target))) return true; - return game.world.is_blocking_at(point); + return sim.world.is_blocking_at(point); }; piece.position = ray_march(piece.position, piece.angle, piece.stats.range, isBlocked); - if (game.world.is_blocking_at(piece.position)) - hook_attach(game, piece); + if (sim.world.is_blocking_at(piece.position)) + hook_attach(sim, piece); } } // Bursts a rocket, mine or fireball into a pushing explosion, keeping the damage raised by any ricochets and cut by // how far it flew. The // blast grows with trinkets but not with the attack's own size, so a big fireball keeps its listed area. -void Weapons::attack_explode(Game& game, Attack& attack) +void Weapons::attack_explode(Sim& sim, Attack& attack) { attack.isDead = true; attack.hitsLeft = 0; - blast_spawn(game, { + blast_spawn(sim, { .position = attack.position, .area = blast_area_get(attack), .bonus = strength_get(attack, attack.position), @@ -2228,7 +1766,7 @@ void Weapons::attack_explode(Game& game, Attack& attack) // Queues an explosion that hits everything inside it next tick, weaker toward its edge. Pushing blasts fling every // fighter away from the centre, harder the closer they are. -void Weapons::blast_spawn(Game& game, Blast blast) +void Weapons::blast_spawn(Sim& sim, Blast blast) { Attack attack = { .stats = {.damage = blast.damage, .range = blast.area}, @@ -2248,9 +1786,9 @@ void Weapons::blast_spawn(Game& game, Blast blast) pending.push_back(attack); float reach = blast.area * KNOCKBACK_REACH; - for (int i = 0; i < game.fighterCount; i++) + for (int i = 0; i < sim.fighterCount; i++) { - Fighter& fighter = game.fighters[i]; + Fighter& fighter = sim.fighters[i]; Vec2 away = fighter.center_get() - blast.position; float distance = vec2_length(away); // Shock Absorber softens its wearer's own blasts as weapon movement, and anyone else's as knockback. @@ -2260,93 +1798,9 @@ void Weapons::blast_spawn(Game& game, Blast blast) (1 - distance / reach) * absorb); } - game.audio.play(blast.sound); - game.effects.shake_add(EXPLOSION_DEF.shake); - game.effects.burst(BLAST_BURST, blast.position, 0, game.color_get(blast.owner)); -} - -// The held weapon's turn away from the aim: a quick eased sweep from the kick to the follow-through, which then -// settles back to the aim. -float Weapons::kick_get(Fighter& fighter) -{ - if (fighter.kickTime < KICK_SWEEP) - { - float t = fighter.kickTime / KICK_SWEEP; - return math_lerp(fighter.kickFrom, fighter.kickTo, t * (2 - t)); - } - return fighter.kickTo * math_pow(KICK_DECAY, (fighter.kickTime - KICK_SWEEP) / TICK); -} - -// A reloading weapon's pose through its cooldown: once the kick has played out it tips up to stand vertical, dips -// down and back like a pump, then swings back to the aim just as it's ready. -static void reload_pose(Fighter& fighter, int key, float cooldown, Draw& draw) -{ - if (!WEAPON_DEFS[fighter.weaponLoadout[key]].isReloading || cooldown <= 0) - return; - float total = fighter.level_get(key).cooldown / fighter.trinket_get(TRINKET_GLOVES, 1); - float t = std::clamp((1 - cooldown / total - RELOAD_START) / (1 - RELOAD_START), 0.0f, 1.0f); - float weight = math_sin(PI * t); - draw.angle = angle_lerp(draw.angle, -PI / 2, weight); - draw.position.y += RELOAD_DROP * math_pow(weight, RELOAD_PUMP); -} - -// How far a fighter's weapons fill in with its colour: with the charge as it builds, or the speed bonus if that's more, -// then once the charge is ready blinking between full and the speed bonus alone. -float Weapons::charge_fill_get(Fighter& fighter) -{ - float buff = buff_get(fighter); - if (fighter.charge >= 1) - return fighter.is_flashing() ? 1.0f : buff; - return std::max(buff, fighter.charge); -} - -// Draws the selected weapon in the player's hand, aimed at the cursor, unless it is out attacking. It fills in as it -// charges (see charge_fill_get) and shakes harder the further the charge has got. A charging mine weapon holds up the -// mine it's about to place instead. -void Weapons::held_draw(Game& game, Fighter& fighter) -{ - int held = fighter.weapon_key(fighter.slot); - if (held < 0 || (is_busy(fighter, held) && !tether_get(fighter, held, false))) - return; - - float side = fighter.is_left() ? -1 : 1; - Draw draw = { - .color = game.color_get(fighter.index), - .position = fighter.hand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), - .scale = {1, side}, - .angle = vec2_angle(fighter.aim) + kick_get(fighter) * side, - .flash = charge_fill_get(fighter), - }; - if (fighter.charge > 0 && WEAPON_DEFS[fighter.weaponLoadout[held]].charged.kind == ATTACK_MINE) - { - draw.scale = {1, 1}; - draw.angle = 0; - game.sprites.effects.draw(game.renderer, "Mine", 0, draw); - return; - } - reload_pose(fighter, held, fighter.cooldowns[held], draw); - hand_draw(game, fighter, held, false, draw); -} - -// Dual Wield: the off-hand weapon, a darker shade of the fighter's colour, held in the far hand behind it. -void Weapons::offhand_draw(Game& game, Fighter& fighter) -{ - int off = fighter.off_slot_get(); - off = off < 0 ? -1 : fighter.weapon_key(off); - if (!fighter.is_trinket_equipped(TRINKET_DUAL) || off < 0 || - (is_busy(fighter, off) && !tether_get(fighter, off, true))) - return; - - float side = fighter.is_left() ? -1 : 1; - Draw draw = { - .color = color_shade(game.color_get(fighter.index), SECONDARY_SHADE), - .position = fighter.offhand_get() + jitter_get(fighter.charge * CHARGE_SHAKE), - .scale = {1, side}, - .angle = vec2_angle(fighter.aim), - .flash = charge_fill_get(fighter), - }; - reload_pose(fighter, off, fighter.cooldowns[off], draw); - hand_draw(game, fighter, off, true, draw); + sim.cues->sound_play(blast.sound); + sim.cues->shake_add(EXPLOSION_DEF.shake); + sim.cues->burst_spawn(BLAST_BURST, blast.position, 0, blast.owner); } // The whip lashing or hook flying out from this hand, which leaves only the weapon's base in it. @@ -2362,29 +1816,6 @@ Attack* Weapons::tether_get(Fighter& fighter, int key, bool isOffhand) return nullptr; } -// Draws a weapon in a hand. While its whip or hook is out, the hand holds only its base (" Base"), pointing -// down the lash or rope. -void Weapons::hand_draw(Game& game, Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw) -{ - Attack* tether = tether_get(fighter, weaponSlot, isOffhand); - std::string animation = WEAPON_DEFS[fighter.weaponLoadout[weaponSlot]].name; - if (tether) - { - Vec2 end = tether->def()->kind == ATTACK_WHIP ? tether->tip : tether->position; - draw.angle = vec2_angle(end - draw.position); - animation += " Base"; - } - // A weapon without held art yet shows its icon, small and upright. - if (!game.sprites.weapons.animation_get(animation.c_str())) - { - draw.scale = {HELD_ICON_SCALE * (draw.scale.y < 0 ? -1 : 1), HELD_ICON_SCALE}; - draw.angle = 0; - game.sprites.icons.draw(game.renderer, animation.c_str(), 0, draw); - return; - } - game.sprites.weapons.draw(game.renderer, animation.c_str(), 0, draw); -} - // The fighter's own attacks it can stand on or bump into: stuck arrows and charged boomerangs in flight. std::vector Weapons::solids_get(Fighter& fighter) { @@ -2437,7 +1868,7 @@ void Weapons::attacks_release(Fighter& fighter, AttackKind kind) // A fresh press calls thrown weapons back: Charge recalls boomerangs once all are out, Attack or Charge // reels in a flail ball. Reports whether it did. -bool Weapons::is_recalled(Game& game, Fighter& fighter) +bool Weapons::is_recalled(Sim& sim, Fighter& fighter) { int key = fighter.weapon_key(fighter.slot); if (key < 0) @@ -2461,9 +1892,9 @@ bool Weapons::is_recalled(Game& game, Fighter& fighter) // Swings the thrown ball under gravity on its chain, then lets the world's tile collision move it, // adding a small bounce and ground friction. Hard landings thud. -void Weapons::flail_move(Game& game, Attack& attack) +void Weapons::flail_move(Sim& sim, Attack& attack) { - Fighter& fighter = game.fighters[attack.owner]; + Fighter& fighter = sim.fighters[attack.owner]; attack.velocity.y += GRAVITY * TICK; Vec2 hand = fighter.hand_get(); Vec2 offset = attack.position + attack.velocity * TICK - hand; @@ -2476,11 +1907,11 @@ void Weapons::flail_move(Game& game, Attack& attack) .velocity = attack.velocity, }; Vec2 before = body.velocity; - game.world.body_move(body); + sim.world.body_move(body); bool isThudX = body.velocity.x == 0 && std::abs(before.x) > FLAIL_THUD_SPEED; bool isThudY = body.velocity.y == 0 && std::abs(before.y) > FLAIL_THUD_SPEED; if (isThudX || isThudY) - impact_play(game, ATTACK_FLAIL); + impact_play(sim, ATTACK_FLAIL); float bounce = std::min(FLAIL_BOUNCE_MAX, FLAIL_BOUNCE + FLAIL_BOUNCE_PER_RICOCHET * fighter.trinket_get(TRINKET_RICOCHET, 0)); diff --git a/src/weapon.h b/src/weapon.h index a97043a..2879a7d 100644 --- a/src/weapon.h +++ b/src/weapon.h @@ -1,14 +1,18 @@ #pragma once -#include "anm2.h" #include "audio.h" -#include "renderer.h" +#include "common.h" #include #include +constexpr float BLAST_RING_ALPHA = 0.2f; +constexpr float RICOCHET_BONUS = 0.5f; +constexpr int WHIP_RAYS = 9; +constexpr float WHIP_UNFURL = 0.6f; + class Fighter; struct Dummy; -struct Game; +struct Sim; constexpr int LEVEL_COUNT = 3; constexpr int WEAPON_COUNT = 16; @@ -786,6 +790,14 @@ struct Capsule float radius = 0; }; +// How attacks weaken and fill in, and the shapes they hit with, shared with drawing them. +float falloff_at(float distance); +float blast_area_get(Attack& attack); +float strength_get(Attack& attack, Vec2 point); +float fill_get(float strength); +std::vector lash_get(Attack& attack); +int shapes_get(Sim& sim, Attack& attack, Capsule* shapes); + // 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 // from MAX_FIGHTERS on. @@ -845,58 +857,56 @@ struct Echo int weaponSlot = 0; }; +// One line the held weapon's attack would follow if fired now, through its steps, and the blast it would ring where +// it ends (zero area for none). +struct Path +{ + std::vector steps; + float area = 0; +}; + // Owns every attack in the world, spawns attacks from fighters and resolves // their hits. Each attack records the fighter that owns it. class Weapons { + friend class Scene; + public: std::vector attacks; float orbitClock = 0; - void fire(Game& game, Fighter& fighter, float charge); - void update(Game& game); - void draw(Game& game); - void blast_draw(Game& game); - void collision_draw(Game& game); + void fire(Sim& sim, Fighter& fighter, float charge); + void update(Sim& sim); void blend(float alpha); - void held_draw(Game& game, Fighter& fighter); - void offhand_draw(Game& game, Fighter& fighter); - void trajectory_draw(Game& game, Fighter& fighter); + std::vector paths_get(Sim& sim, Fighter& fighter); float buff_get(Fighter& fighter); - float charge_fill_get(Fighter& fighter); - Vec2 jitter_get(float fill); - void blast_spawn(Game& game, Blast blast); + void blast_spawn(Sim& sim, Blast blast); Attack* hook_get(Fighter& fighter); void attacks_release(Fighter& fighter, AttackKind kind); - bool is_recalled(Game& game, Fighter& fighter); + bool is_recalled(Sim& sim, Fighter& fighter); bool is_busy(Fighter& fighter, int key); bool is_spinning(Fighter& fighter); - void damage_deal(Game& game, Damage damage); - void contact_update(Game& game, Fighter& fighter); - void wisp_move(Game& game, Attack& attack); + void damage_deal(Sim& sim, Damage damage); + void contact_update(Sim& sim, Fighter& fighter); + void wisp_move(Sim& sim, Attack& attack); void wisps_release(Fighter& fighter); - void teleport_move(Game& game, Attack& attack); - void magic_draw(Game& game); + void teleport_move(Sim& sim, Attack& attack); std::vector solids_get(Fighter& fighter); private: std::vector pending; - bool is_spawned(Game& game, Fighter& fighter, Shot shot); - void attack_move(Game& game, Attack& attack); + bool is_spawned(Sim& sim, Fighter& fighter, Shot shot); + void attack_move(Sim& sim, Attack& attack); void attack_remember(Attack& attack); - void attack_hit(Game& game, Attack& attack, int target, Vec2 point); - std::vector chain_get(Game& game, Fighter& fighter); - void tesla_update(Game& game); - void tesla_draw(Game& game); - Pose attack_pose(Game& game, Attack& attack); + void attack_hit(Sim& sim, Attack& attack, int target, Vec2 point); + std::vector chain_get(Sim& sim, Fighter& fighter); + void tesla_update(Sim& sim); Attack* tether_get(Fighter& fighter, int key, bool isOffhand); - void hand_draw(Game& game, Fighter& fighter, int weaponSlot, bool isOffhand, Draw draw); - float kick_get(Fighter& fighter); - void hook_attach(Game& game, Attack& attack); - void piece_launch(Game& game, Attack& piece); - void attack_explode(Game& game, Attack& attack); - void flail_move(Game& game, Attack& attack); - void orbits_update(Game& game); - void attacks_cap(Game& game); + void hook_attach(Sim& sim, Attack& attack); + void piece_launch(Sim& sim, Attack& piece); + void attack_explode(Sim& sim, Attack& attack); + void flail_move(Sim& sim, Attack& attack); + void orbits_update(Sim& sim); + void attacks_cap(Sim& sim); }; diff --git a/src/world.cpp b/src/world.cpp index 7e835b7..b7c6011 100644 --- a/src/world.cpp +++ b/src/world.cpp @@ -1,7 +1,7 @@ #include "world.h" -#include "game.h" -#include +#include "sim.h" #include +#include #include constexpr const char* ARENA[] = { @@ -185,7 +185,6 @@ constexpr const char* const* COURSE_CHUNKS[] = {CHUNK_DOORS, CHUNK_PITS, CHUN CHUNK_VALLEY, CHUNK_STEEP, CHUNK_SKATE}; constexpr int COURSE_LENGTH = 8; -constexpr int CORNER_SIZE = 2; constexpr int PLATFORM_HEIGHT = 8; constexpr float BODY_EPSILON = 0.01f; constexpr float BODY_STEP = TILE_SIZE / 2.0f; @@ -195,32 +194,23 @@ constexpr int STEEP_RAMP = 4; constexpr float JUMP_SLACK = 60; constexpr float RAMP_ANGLE_STEP = PI / 12; constexpr float RAMP_EPSILON = 0.001f; -constexpr float RAMP_OUTLINE = 2; constexpr float STICK_SLACK = 1; constexpr float STICK_SLOPE = 1.75f; constexpr float STEP_UP = BOX_WIDTH / 2 * STICK_SLOPE + STICK_SLACK; constexpr Burst BREAK_BURST = {.gravity = 500, .life = 0.6f, .size = 4, .speed = 160, .count = 14}; -constexpr float BLOCK_HEALTH = 30; constexpr float BLOCK_REGROW = 8; -constexpr float BLOCK_WARN = 1; constexpr float DUMMY_HEIGHT = 28; constexpr float DUMMY_WIDTH = 20; constexpr float FLASH_DECAY = 8; constexpr float WOBBLE_DAMPING = 5; constexpr float WOBBLE_LIMIT = 0.7f; constexpr float WOBBLE_STIFFNESS = 140; -constexpr Vec2 CORNER_DIRECTIONS[] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}}; static bool is_solid(Tile tile) { return tile == TILE_SOLID || tile == TILE_BLOCK; } -void World::init(Game& game) -{ - sandbox_load(); -} - // The arena with the practice dummy standing on the first surface below the middle of the map, and the player // spawning right underneath it, on the next surface down. void World::sandbox_load() @@ -256,7 +246,7 @@ void World::arena_load() void World::course_build(Rng& rng) { std::vector rows(CHUNK_START, CHUNK_START + COURSE_HEIGHT); - const char* forced = SDL_getenv("RPG_FORCE_CHUNK"); + const char* forced = std::getenv("RPG_FORCE_CHUNK"); int previous = -1; for (int i = 0; i < COURSE_LENGTH; i++) { @@ -265,7 +255,7 @@ void World::course_build(Rng& rng) pick = rng.random_get(0, std::size(COURSE_CHUNKS)); previous = pick; if (forced) - pick = SDL_atoi(forced); + pick = std::atoi(forced); for (int y = 0; y < COURSE_HEIGHT; y++) rows[y] += COURSE_CHUNKS[pick][y]; } @@ -353,17 +343,17 @@ void World::ramps_load(std::vector& rows) } // Knocks a block out of the grid in a shower of debris in the breaker's colour, to grow back later. -void World::block_break(Game& game, Dummy& block, int breaker) +void World::block_break(Sim& sim, Dummy& block, int breaker) { tiles[block.tile] = TILE_EMPTY; block.regrow = BLOCK_REGROW; Vec2 center = {block.box.x + block.box.w / 2, block.box.y + block.box.h / 2}; - game.effects.burst(BREAK_BURST, center, 0, game.color_get(breaker)); - game.audio.play(SOUND_BREAK); + sim.cues->burst_spawn(BREAK_BURST, center, 0, breaker); + sim.cues->sound_play(SOUND_BREAK); } // Dummies wobble back upright, and broken blocks grow back once their time is up and no fighter stands in them. -void World::update(Game& game) +void World::update(Sim& sim) { for (Dummy& dummy : dummies) { @@ -371,8 +361,8 @@ void World::update(Game& game) { dummy.regrow -= TICK; bool isClear = true; - for (int i = 0; i < game.fighterCount; i++) - isClear &= !game.fighters[i].isAlive || !is_rect_overlapping(game.fighters[i].body.box, dummy.box); + for (int i = 0; i < sim.fighterCount; i++) + isClear &= !sim.fighters[i].isAlive || !is_rect_overlapping(sim.fighters[i].body.box, dummy.box); if (dummy.regrow <= 0 && isClear) { tiles[dummy.tile] = TILE_BLOCK; @@ -386,98 +376,6 @@ void World::update(Game& game) } } -void World::draw(Game& game) -{ - Renderer& renderer = game.renderer; - Anm2& tileArt = game.sprites.tiles; - int startX = std::max(0, (int)std::floor(renderer.camera.x / TILE_SIZE)); - int startY = std::max(0, (int)std::floor(renderer.camera.y / TILE_SIZE)); - int endX = std::min(width - 1, (int)std::floor((renderer.camera.x + CANVAS_WIDTH) / TILE_SIZE)); - int endY = std::min(height - 1, (int)std::floor((renderer.camera.y + CANVAS_HEIGHT) / TILE_SIZE)); - - for (int y = startY; y <= endY; y++) - { - for (int x = startX; x <= endX; x++) - { - auto isOpen = [&](float dx, float dy) { - Tile neighbour = tile_get(x + dx, y + dy); - return neighbour != TILE_SOLID && neighbour != TILE_SLOPE; - }; - auto isEmpty = [&](int dx) { - return tile_get(x + dx, y) == TILE_EMPTY; - }; - Tile tile = tile_get(x, y); - Vec2 position = {(float)x * TILE_SIZE, (float)y * TILE_SIZE}; - - if (tile == TILE_SOLID) - { - int mask = isOpen(0, -1) | isOpen(1, 0) << 1 | isOpen(0, 1) << 2 | isOpen(-1, 0) << 3; - tileArt.draw(renderer, "Wall", mask, {.position = position}); - - for (Vec2 corner : CORNER_DIRECTIONS) - { - if (isOpen(corner.x, 0) || isOpen(0, corner.y) || !isOpen(corner.x, corner.y)) - continue; - float cornerX = corner.x < 0 ? position.x : position.x + TILE_SIZE - CORNER_SIZE; - float cornerY = corner.y < 0 ? position.y : position.y + TILE_SIZE - CORNER_SIZE; - renderer.rect_draw({cornerX, cornerY, CORNER_SIZE, CORNER_SIZE}, WHITE); - } - } - else if (tile == TILE_PLATFORM) - { - int mask = isEmpty(1) | isEmpty(-1) << 1; - tileArt.draw(renderer, "Platform", mask, {.position = position}); - } - else if (tile == TILE_PIT || tile == TILE_FINISH) - tileArt.draw(renderer, tile == TILE_PIT ? "Pit" : "Finish", 0, {.position = position}); - } - } - - // Ramps: an outline just inside the slope and its flat top, and a wall down the high end where it stands free. - for (Ramp& ramp : ramps) - { - if (ramp.right < renderer.camera.x || ramp.left > renderer.camera.x + CANVAS_WIDTH) - continue; - float edge = ramp.anchor + (ramp.top - ramp.base) / ramp.slope; - Vec2 low = {ramp.anchor, ramp.base}; - Vec2 high = {edge, ramp.top}; - Vec2 end = {ramp.slope < 0 ? ramp.right : ramp.left, ramp.top}; - Vec2 inset = vec2_normalize({ramp.slope, -1}) * (-RAMP_OUTLINE / 2); - renderer.line_draw(low + inset, high + inset, RAMP_OUTLINE, WHITE); - renderer.line_draw({high.x, high.y + RAMP_OUTLINE / 2}, {end.x, end.y + RAMP_OUTLINE / 2}, RAMP_OUTLINE, WHITE); - - float side = ramp.slope < 0 ? 1 : -1; - Vec2 beyond = {end.x + side * TILE_SIZE / 2, ramp.top + TILE_SIZE / 2}; - if (tile_at(beyond) != TILE_SOLID) - renderer.line_draw({end.x - side * RAMP_OUTLINE / 2, ramp.top}, - {end.x - side * RAMP_OUTLINE / 2, ramp.base}, RAMP_OUTLINE, WHITE); - } - - // Blocks crack as they lose health, and a broken one shows a faint outline just before it grows back. - for (Dummy& dummy : dummies) - { - if (dummy.tile < 0) - continue; - Vec2 position = {dummy.box.x, dummy.box.y}; - if (tiles[dummy.tile] == TILE_BLOCK) - { - int stage = std::min(BLOCK_STAGES - 1, (int)((1 - dummy.health / BLOCK_HEALTH) * BLOCK_STAGES)); - tileArt.draw(renderer, "Block", stage, {.position = position, .flash = dummy.flash}); - } - else if (dummy.regrow < BLOCK_WARN) - tileArt.draw(renderer, "Block", 0, - {.color = color_fade(GRAY, 1 - dummy.regrow / BLOCK_WARN), .position = position}); - } - - for (Dummy& dummy : dummies) - { - if (dummy.tile >= 0) - continue; - Vec2 base = {dummy.box.x + dummy.box.w / 2, dummy.box.y + dummy.box.h}; - game.sprites.dummy.draw(renderer, "Idle", 0, {.position = base, .angle = dummy.wobble, .flash = dummy.flash}); - } -} - // Moves a body by its velocity for one tick, in steps no longer than half a tile so fast bodies can't skip over // or sink into walls. void World::body_move(Body& body) diff --git a/src/world.h b/src/world.h index e1683cb..d2fcabf 100644 --- a/src/world.h +++ b/src/world.h @@ -5,7 +5,9 @@ #include #include -struct Game; +constexpr float BLOCK_HEALTH = 30; + +struct Sim; enum Tile : unsigned char { @@ -93,13 +95,11 @@ public: int height = 0; int width = 0; - void init(Game& game); - void update(Game& game); - void draw(Game& game); + void update(Sim& sim); void arena_load(); void sandbox_load(); void course_build(Rng& rng); - void block_break(Game& game, Dummy& block, int breaker); + void block_break(Sim& sim, Dummy& block, int breaker); void body_move(Body& body); Tile tile_get(int x, int y); Tile tile_at(Vec2 point);