From 465f75f6d74be0869f4ad248390016daf75277cd Mon Sep 17 00:00:00 2001 From: shweet Date: Sun, 8 Nov 2026 01:46:07 -0500 Subject: [PATCH] Split the simulation from presentation into an SDL-free library Sim holds one whole game (tick, snapshots, checksum) and talks to the screen only through Cues, which the client's Scene plays and the server ignores. All drawing moved to scene.cpp. Match takes this screen's wishes as request flags instead of reading the menu, input and settings. The sim library builds without SDL and the server links it, ready to run games itself. Co-Authored-By: Claude Opus 5.5 --- CMakeLists.txt | 5 +- server/CMakeLists.txt | 8 +- src/audio.cpp | 4 +- src/audio.h | 2 - src/balance.cpp | 44 +- src/bot.cpp | 140 +++--- src/bot.h | 23 +- src/chat.cpp | 14 +- src/common.h | 34 +- src/effects.cpp | 14 - src/effects.h | 19 +- src/fighter.cpp | 295 ++++-------- src/fighter.h | 46 +- src/game.h | 37 +- src/main.cpp | 78 ++-- src/match.cpp | 323 +++++++------ src/match.h | 50 +- src/menu.cpp | 56 ++- src/renderer.h | 15 +- src/rollback.cpp | 95 +--- src/rollback.h | 23 +- src/scene.cpp | 975 +++++++++++++++++++++++++++++++++++++++ src/scene.h | 59 +++ src/session.cpp | 188 ++++---- src/settings.cpp | 20 +- src/sim.cmake | 18 + src/sim.cpp | 103 +++++ src/sim.h | 64 +++ src/sprites.cpp | 7 - src/sprites.h | 1 - src/state.cpp | 32 +- src/state.h | 6 +- src/ui.cpp | 158 +++---- src/ui.h | 1 - src/weapon.cpp | 1021 +++++++++-------------------------------- src/weapon.h | 80 ++-- src/world.cpp | 122 +---- src/world.h | 10 +- 38 files changed, 2225 insertions(+), 1965 deletions(-) create mode 100644 src/scene.cpp create mode 100644 src/scene.h create mode 100644 src/sim.cmake create mode 100644 src/sim.cpp create mode 100644 src/sim.h 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);