diff --git a/common/include/common/version/version.h b/common/include/common/version/version.h index a70af926..26021f6f 100644 --- a/common/include/common/version/version.h +++ b/common/include/common/version/version.h @@ -34,6 +34,7 @@ #define ADS_VERSION 2 // .toml dedicated server config files #define AFS_VERSION 14 // alpine_settings.ini #define AFCC_VERSION 2 // alpine_system.ini +#define AFL_VERSION 1 // .afl per-level saved info // Increment only when absolutely necessary! Will break level compatibility for older clients. // Stock RF1 uses versions 156 - 200. RF2 uses version 295 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 792d78bd..ff16d431 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -45,6 +45,13 @@ Version 1.4.0 (Lupin): Not yet released - Require a fresh `Alt` press to kill an unresponsive process - Add mini scoreboard HUD element to FFA game types - Add `ui_minisb_dm` console command to toggle whether mini scoreboard is displayed in DM mode +- Add `Attach Spectate Camera` control to toggle spectate between following a player and a detached free camera +- Add `Change Spectate View` control to switch first/third person while following a player, or free look/static camera while detached +- Add a middle mouse toggled orbit camera to third person spectate +- Add stepped zoom to free look spectate +- Add the ability to drop reusable static cameras in free look spectate, then cycle level-placed and player-dropped cameras in static camera view +- Add numpad quick-binds to jump directly to bound players or cameras while spectating, with dropped cameras and binds persisted per level +- Add `spectate_cameras` console command to toggle showing camera meshes at static camera locations while free look spectating [@is-this-c](https://github.com/is-this-c) - Add `Anti-aliasing` option to `ADVANCED` options panel diff --git a/game_patch/CMakeLists.txt b/game_patch/CMakeLists.txt index 8d509697..bf260ccf 100644 --- a/game_patch/CMakeLists.txt +++ b/game_patch/CMakeLists.txt @@ -157,6 +157,8 @@ set(SRCS multi/kill.cpp multi/network.cpp multi/network.h + multi/saved_info.cpp + multi/saved_info.h multi/level_download.cpp multi/server.h multi/server.cpp diff --git a/game_patch/graphics/gr.cpp b/game_patch/graphics/gr.cpp index 674fb8a6..4e15b464 100644 --- a/game_patch/graphics/gr.cpp +++ b/game_patch/graphics/gr.cpp @@ -187,6 +187,8 @@ CodeInjection gameplay_render_frame_fov_injection{ // Scale world FOV auto& rf_fov = addr_as_ref(0x0059613C); rf_fov = gr_scale_world_fov(rf_fov); + // Free-look spectate stepped zoom narrows the FOV (1.0 when not zoomed) + rf_fov /= multi_spectate_get_view_fov_scale(); }, }; diff --git a/game_patch/hud/multi_spectate.cpp b/game_patch/hud/multi_spectate.cpp index 43395ad1..800e8644 100644 --- a/game_patch/hud/multi_spectate.cpp +++ b/game_patch/hud/multi_spectate.cpp @@ -8,6 +8,7 @@ #include "../rf/level.h" #include "../rf/player/player.h" #include "../rf/multi.h" +#include "../rf/os/console.h" #include "../rf/gameseq.h" #include "../rf/weapon.h" #include "../rf/gr/gr.h" @@ -16,12 +17,26 @@ #include "../rf/bmpman.h" #include "../rf/player/camera.h" #include "../rf/player/player_fpgun.h" +#include "../rf/vmesh.h" +#include "../rf/v3d.h" +#include "../rf/collide.h" +#include "../rf/geometry.h" +#include "../rf/math/matrix.h" #include "../main/main.h" +#include "../input/mouse.h" #include "../misc/player.h" #include "../misc/alpine_settings.h" #include "../multi/gametype.h" +#include "../multi/saved_info.h" #include #include +#include +#include +#include +#include +#include +#include +#include "../rf/input.h" #include #include #include @@ -32,10 +47,97 @@ static rf::Player* g_spectate_mode_target; static rf::Camera* g_old_target_camera = nullptr; -static bool g_spectate_mode_enabled = false; +static bool g_spectate_mode_enabled = false; // attached camera spectate static bool g_spectate_mode_follow_killer = false; static rf::Player* g_spectate_freelook_saved_target = nullptr; +// Two spectate groups, each with its own submode. The "attached" group watches a player +// (first or third person); the "detached" group is a free camera (free look or static). +// The Attach bind swaps between groups; Change Spectate View flips the submode in the group. +enum class SpectateViewMode +{ + first_person, + third_person, + freelook, + static_cam, +}; +static SpectateViewMode g_spectate_view_mode = SpectateViewMode::first_person; +// Session-scoped submode memory: survives leaving/re-entering spectate and level changes. +static SpectateViewMode g_spectate_attached_submode = SpectateViewMode::first_person; +static SpectateViewMode g_spectate_detached_submode = SpectateViewMode::freelook; +static bool g_spectate_last_attached = true; // which group to restore on re-enter +static bool g_spectate_third_person_orbit = false; // MMB toggle; default off, non-persistent +static bool g_spectate_static_active = false; // in static-camera spectate + +// Static camera selection. The cycle runs over [0, level cameras + dropped cameras). Indices below +// the level count are rendered by the engine's fixed view; higher ones are player-dropped cameras +// positioned by multi_spectate_camera_do_frame. +struct SpectateStaticCamera +{ + int id; // stable per-level identifier, persisted so numpad binds can reference dropped cameras + rf::Vector3 pos; + rf::Matrix3 orient; +}; +static std::vector g_spectate_dropped_cameras; +static int g_spectate_static_index = 0; + +// Marker mesh drawn at each static camera location while free look spectating. +static rf::VMesh* g_spectate_camera_mesh = nullptr; +static bool g_spectate_camera_mesh_load_attempted = false; + +// Numpad quick-bind: numpad 0-9 jump to a bound player (first/third person/orbit) or static camera. +// Numpad Enter opens/closes a bind dialog; while it's open, a numpad number binds the current +// player/camera to that key. +static bool g_spectate_bind_dialog_open = false; +static constexpr int k_spectate_numpad_count = 10; // number of numpad quick-bind slots (0-9) +static rf::Player* g_spectate_player_binds[k_spectate_numpad_count] = {}; // numpad key -> spectated player +static int g_spectate_static_binds[k_spectate_numpad_count] = {-1, -1, -1, -1, -1, -1, -1, -1, -1, -1}; // -> static index +static const rf::Key g_spectate_numpad_keys[k_spectate_numpad_count] = { + rf::KEY_PAD0, rf::KEY_PAD1, rf::KEY_PAD2, rf::KEY_PAD3, rf::KEY_PAD4, + rf::KEY_PAD5, rf::KEY_PAD6, rf::KEY_PAD7, rf::KEY_PAD8, rf::KEY_PAD9, +}; + +// Third person orbit state. Angles are absolute (world space); mouse moves them. +static float g_spectate_orbit_yaw = 0.0f; +static float g_spectate_orbit_pitch = 0.0f; +static constexpr float k_spectate_orbit_distance = 4.5f; +static constexpr float k_spectate_orbit_focus_height = 1.0f; +static constexpr float k_spectate_orbit_pitch_limit = 1.4f; // ~80 degrees + +// Free look stepped zoom: tapping "next" (primary attack) steps through these FOV divisors and wraps. +static constexpr float k_spectate_freelook_zoom_steps[] = {1.0f, 2.0f, 4.0f}; +static int g_spectate_freelook_zoom_index = 0; + +static bool spectate_is_player_view(SpectateViewMode mode) +{ + return mode == SpectateViewMode::first_person || mode == SpectateViewMode::third_person; +} + +// First-person-only render path (target's fpgun, scope, reticle) is active only in this view. +static bool spectate_first_person_render_active() +{ + return g_spectate_mode_enabled && g_spectate_view_mode == SpectateViewMode::first_person; +} + +bool multi_spectate_is_third_person_orbit() +{ + return g_spectate_mode_enabled && g_spectate_view_mode == SpectateViewMode::third_person + && g_spectate_third_person_orbit; +} + +bool multi_spectate_is_static() +{ + return g_spectate_static_active; +} + +float multi_spectate_get_view_fov_scale() +{ + if (g_spectate_view_mode == SpectateViewMode::freelook && multi_spectate_is_freelook()) { + return k_spectate_freelook_zoom_steps[g_spectate_freelook_zoom_index]; + } + return 1.0f; +} + // Edge-detection state for spectated player action animations static bool g_prev_weapon_is_on = false; static bool g_prev_is_reloading = false; @@ -44,6 +146,12 @@ static int g_prev_weapon_type = -1; void player_fpgun_set_player(rf::Player* pp); +static void spectate_apply_player_view_mode(); +static void spectate_init_orbit(rf::Camera* camera); +static void spectate_populate_default_binds(); +static void spectate_save_afl(); +static void spectate_delete_current_dropped_camera(); + static void set_camera_target(rf::Player* player) { // Based on function set_camera1_view @@ -151,7 +259,7 @@ void multi_spectate_set_target_player(rf::Player* player) g_old_target_camera = nullptr; #if SPECTATE_MODE_SHOW_WEAPON - g_spectate_mode_target->flags &= ~(1u << 4); + g_spectate_mode_target->flags &= ~rf::PF_HIDE_FROM_CAMERA; rf::Entity* entity = rf::entity_from_handle(g_spectate_mode_target->entity_handle); if (entity) entity->local_player = nullptr; @@ -170,6 +278,17 @@ void multi_spectate_set_target_player(rf::Player* player) } g_spectate_mode_enabled = entering_player_spectate; + if (entering_player_spectate && !spectate_is_player_view(g_spectate_view_mode)) { + // Entering attached spectate from a free view (e.g. the `spectate ` console command + // in free look) - default to first person so the view-mode state stays consistent. Keep the + // remembered attached submode in sync too, so a later detach/reattach doesn't snap to a + // stale submode. + g_spectate_view_mode = SpectateViewMode::first_person; + g_spectate_attached_submode = SpectateViewMode::first_person; + } + if (entering_player_spectate) { + spectate_populate_default_binds(); // seed numpad binds with the top players (if none set yet) + } if (g_spectate_mode_target != player) { af_send_spectate_start_packet(player); g_spectate_mode_target = player; @@ -184,7 +303,7 @@ void multi_spectate_set_target_player(rf::Player* player) set_camera_target(player); #if SPECTATE_MODE_SHOW_WEAPON - player->flags |= 1u << 4; + player->flags |= rf::PF_HIDE_FROM_CAMERA; player->fpgun_data.fpgun_weapon_type = -1; player->weapon_mesh_handle = nullptr; rf::Entity* entity = rf::entity_from_handle(player->entity_handle); @@ -198,6 +317,13 @@ void multi_spectate_set_target_player(rf::Player* player) } player_fpgun_set_player(player); #endif // SPECTATE_MODE_SHOW_WEAPON + + // The block above sets up first-person spectate. If the active view is third person / orbit, + // convert to it (show the body, attach the camera behind/around the target). + if (spectate_is_player_view(g_spectate_view_mode) + && g_spectate_view_mode != SpectateViewMode::first_person) { + spectate_apply_player_view_mode(); + } } static void spectate_next_player(const bool dir, const bool try_alive_players_first = false) { @@ -235,6 +361,8 @@ void multi_spectate_enter_freelook() if (!rf::local_player || !rf::local_player->cam || !rf::is_multi) return; + g_spectate_view_mode = SpectateViewMode::freelook; + g_spectate_freelook_zoom_index = 0; rf::multi_kill_local_player(); rf::camera_enter_freelook(rf::local_player->cam); g_local_queued_delayed_spawn = false; @@ -256,65 +384,524 @@ bool multi_spectate_is_freelook() bool multi_spectate_is_spectating() { - return g_spectate_mode_enabled || multi_spectate_is_freelook(); + return g_spectate_mode_enabled || multi_spectate_is_freelook() || g_spectate_static_active; } bool multi_spectate_is_first_person() { - return g_spectate_mode_enabled; + return spectate_first_person_render_active(); } -void multi_spectate_toggle_freelook() +bool multi_spectate_is_following_player() { - if (!multi_spectate_is_spectating()) - return; + return g_spectate_mode_enabled; +} - if (g_spectate_mode_enabled) { - // Currently in first-person spectate, switch to freelook - // Save the current target so we can resume on the same player when toggling back - g_spectate_freelook_saved_target = g_spectate_mode_target; - - // Clean up the first-person spectate state (restore old target's camera, weapon state) - if (g_spectate_mode_target && g_spectate_mode_target != rf::local_player) { - g_spectate_mode_target->cam = g_old_target_camera; - g_old_target_camera = nullptr; +// Restore the spectator's camera/target binding that a player view set up. Safe when no target. +static void spectate_unbind_target() +{ + if (g_spectate_mode_target && g_spectate_mode_target != rf::local_player) { + g_spectate_mode_target->cam = g_old_target_camera; + g_old_target_camera = nullptr; + if (rf::local_player && rf::local_player->cam) rf::local_player->cam->player = rf::local_player; #if SPECTATE_MODE_SHOW_WEAPON - g_spectate_mode_target->flags &= ~(1u << 4); - rf::Entity* entity = rf::entity_from_handle(g_spectate_mode_target->entity_handle); - if (entity) - entity->local_player = nullptr; + g_spectate_mode_target->flags &= ~rf::PF_HIDE_FROM_CAMERA; + rf::Entity* entity = rf::entity_from_handle(g_spectate_mode_target->entity_handle); + if (entity) + entity->local_player = nullptr; + player_fpgun_set_player(rf::local_player); #endif - } + } +} + +static void spectate_init_orbit(rf::Camera* camera) +{ + // Seed yaw/pitch from the current look direction so entering orbit doesn't snap. + rf::Vector3 fwd = rf::camera_get_orient(camera).fvec; + g_spectate_orbit_yaw = std::atan2(fwd.x, fwd.z); + g_spectate_orbit_pitch = std::clamp(std::asin(std::clamp(fwd.y, -1.0f, 1.0f)), + -k_spectate_orbit_pitch_limit, k_spectate_orbit_pitch_limit); +} - g_spectate_mode_enabled = false; - g_spectate_mode_target = rf::local_player; +// Configure the camera + target body visibility for the current player view. Assumes the camera +// is already bound to g_spectate_mode_target (via multi_spectate_set_target_player). +static void spectate_apply_player_view_mode() +{ + if (!rf::local_player || !rf::local_player->cam || !g_spectate_mode_target + || !spectate_is_player_view(g_spectate_view_mode)) { + return; + } + + rf::Camera* camera = rf::local_player->cam; + if (!camera->camera_entity) + return; // no camera entity to position/orient yet; a later frame re-applies the view + rf::Entity* entity = rf::entity_from_handle(g_spectate_mode_target->entity_handle); + if (g_spectate_view_mode == SpectateViewMode::first_person) { +#if SPECTATE_MODE_SHOW_WEAPON + // Hide the target's body and render their first-person weapon. + g_spectate_mode_target->flags |= rf::PF_HIDE_FROM_CAMERA; + g_spectate_mode_target->fpgun_data.fpgun_weapon_type = -1; + g_spectate_mode_target->weapon_mesh_handle = nullptr; + if (entity) { + rf::player_fpgun_set_state(g_spectate_mode_target, entity->ai.current_primary_weapon); + entity->local_player = g_spectate_mode_target; + } + player_fpgun_set_player(g_spectate_mode_target); +#endif + rf::camera_enter_first_person(camera); + } + else { + // Third person / orbit: show the target's body, drop the first-person weapon overlay. #if SPECTATE_MODE_SHOW_WEAPON + g_spectate_mode_target->flags &= ~rf::PF_HIDE_FROM_CAMERA; + if (entity) + entity->local_player = nullptr; player_fpgun_set_player(rf::local_player); #endif + rf::camera_enter_third_person(camera); + if (g_spectate_view_mode == SpectateViewMode::third_person && g_spectate_third_person_orbit) + spectate_init_orbit(camera); + } +} + +static int g_spectate_saved_fixed_look_target = -1; - // Now enter freelook cleanly +static int spectate_static_camera_count() +{ + return rf::fixed_camera_count + static_cast(g_spectate_dropped_cameras.size()); +} + +static int spectate_next_dropped_camera_id() +{ + int next = 0; + for (const auto& cam : g_spectate_dropped_cameras) { + if (cam.id >= std::numeric_limits::max()) + continue; // avoid signed overflow on cam.id + 1 + next = std::max(next, cam.id + 1); + } + return next; +} + +static void spectate_save_afl() +{ + if (!rf::is_multi) + return; + + toml::array cameras; + for (const auto& cam : g_spectate_dropped_cameras) { + cameras.push_back(toml::table{ + {"id", cam.id}, + {"pos", toml::array{cam.pos.x, cam.pos.y, cam.pos.z}}, + {"rvec", toml::array{cam.orient.rvec.x, cam.orient.rvec.y, cam.orient.rvec.z}}, + {"uvec", toml::array{cam.orient.uvec.x, cam.orient.uvec.y, cam.orient.uvec.z}}, + {"fvec", toml::array{cam.orient.fvec.x, cam.orient.fvec.y, cam.orient.fvec.z}}, + }); + } + + toml::array binds; + for (int i = 0; i < k_spectate_numpad_count; ++i) { + const int idx = g_spectate_static_binds[i]; + if (idx < 0) + continue; + toml::table entry{{"key", i}}; + if (idx < rf::fixed_camera_count) { + entry.insert("level_index", idx); // bound to a camera baked into the .rfl + } + else { + const int di = idx - rf::fixed_camera_count; + if (di < 0 || di >= static_cast(g_spectate_dropped_cameras.size())) + continue; // stale bind - skip + entry.insert("camera_id", g_spectate_dropped_cameras[di].id); + } + binds.push_back(entry); + } + + saved_info_write(toml::table{{"cameras", cameras}, {"binds", binds}}); +} + +static void spectate_load_afl() +{ + const auto root_opt = saved_info_read(); + if (!root_opt) + return; + const toml::table& root = *root_opt; + + if (const auto* cameras = root.get_as("cameras")) { + for (auto&& node : *cameras) { + const auto* entry = node.as_table(); + if (!entry) + continue; + SpectateStaticCamera cam{}; + const auto id = (*entry)["id"].value(); + cam.id = (id && *id >= 0 && *id <= std::numeric_limits::max()) + ? static_cast(*id) + : spectate_next_dropped_camera_id(); + cam.orient.make_identity(); + if (!saved_info_read_vec3(entry->get_as("pos"), cam.pos)) + continue; + saved_info_read_vec3(entry->get_as("rvec"), cam.orient.rvec); + saved_info_read_vec3(entry->get_as("uvec"), cam.orient.uvec); + const bool have_fvec = saved_info_read_vec3(entry->get_as("fvec"), cam.orient.fvec); + // Re-derive a clean orthonormal basis from the stored forward vector so a partial, zero, + // or degenerate saved orientation can't become a bad view matrix. make_quick builds a + // full basis from fvec (the same call the orbit code uses); otherwise fall back to + // identity. saved_info_read_vec3 already guarantees finite components when it succeeds. + const rf::Vector3 fvec = cam.orient.fvec; + if (have_fvec && fvec.len_sq() > 0.0f) { + cam.orient.make_quick(fvec); + } + else { + cam.orient.make_identity(); + } + g_spectate_dropped_cameras.push_back(cam); + } + } + + if (const auto* binds = root.get_as("binds")) { + for (auto&& node : *binds) { + const auto* entry = node.as_table(); + if (!entry) + continue; + const auto key = (*entry)["key"].value(); + if (!key || *key < 0 || *key > 9) + continue; + const int k = static_cast(*key); + if (const auto level_index = (*entry)["level_index"].value()) { + const int li = static_cast(*level_index); + if (li >= 0 && li < rf::fixed_camera_count) + g_spectate_static_binds[k] = li; + } + else if (const auto camera_id = (*entry)["camera_id"].value()) { + if (*camera_id < 0 || *camera_id > std::numeric_limits::max()) + continue; // out-of-range id can't correspond to a real dropped camera + const int cid = static_cast(*camera_id); + for (int j = 0; j < static_cast(g_spectate_dropped_cameras.size()); ++j) { + if (g_spectate_dropped_cameras[j].id == cid) { + g_spectate_static_binds[k] = rf::fixed_camera_count + j; + break; + } + } + } + } + } +} + +// Select the camera for g_spectate_static_index. Level cameras (index < level count) are rendered +// by the engine's fixed view; dropped cameras are positioned in multi_spectate_camera_do_frame. +static void spectate_apply_static_index() +{ + if (!rf::local_player || !rf::local_player->cam) + return; + rf::local_player->cam->mode = rf::CAMERA_FIXED_VIEW; + rf::fixed_camera_look_target_handle = -1; // use stored (level) orientation, not a tracked target + // Keep the engine's fixed-camera index in range. For a player-dropped camera there is no + // matching .rfl entry, so clamp to a real one; the dropped camera itself is positioned each + // frame by multi_spectate_camera_do_frame. Prevents an OOB read of the fixed-camera arrays if + // the stock fixed-view path ever runs. + if (rf::fixed_camera_count > 0) + rf::fixed_camera_index = std::clamp(g_spectate_static_index, 0, rf::fixed_camera_count - 1); +} + +static void spectate_enter_static() +{ + if (!rf::local_player || !rf::local_player->cam || spectate_static_camera_count() <= 0) { + // No cameras to show - stay in free look instead. Keep the remembered submode honest. + g_spectate_detached_submode = SpectateViewMode::freelook; multi_spectate_enter_freelook(); + return; } - else { - // Currently in freelook, switch to first-person spectate - // Try to resume on the saved target if they're still valid - if (g_spectate_freelook_saved_target - && g_spectate_freelook_saved_target != rf::local_player - && !g_spectate_freelook_saved_target->is_browser) { - multi_spectate_set_target_player(g_spectate_freelook_saved_target); + g_spectate_static_active = true; + g_spectate_view_mode = SpectateViewMode::static_cam; + g_spectate_saved_fixed_look_target = rf::fixed_camera_look_target_handle; + g_spectate_static_index = 0; + spectate_apply_static_index(); + af_send_spectate_start_packet(nullptr); +} + +static void spectate_exit_static() +{ + if (!g_spectate_static_active) + return; + g_spectate_static_active = false; + rf::fixed_camera_look_target_handle = g_spectate_saved_fixed_look_target; +} + +static void spectate_cycle_static_camera(bool next) +{ + const int total = spectate_static_camera_count(); + if (total <= 0) + return; + int i = g_spectate_static_index + (next ? 1 : -1); + if (i < 0) + i = total - 1; + else if (i >= total) + i = 0; + g_spectate_static_index = i; + spectate_apply_static_index(); +} + +static void spectate_drop_freelook_camera() +{ + if (!rf::local_player || !rf::local_player->cam) + return; + SpectateStaticCamera cam; + cam.id = spectate_next_dropped_camera_id(); + cam.pos = rf::camera_get_pos(rf::local_player->cam); + cam.orient = rf::camera_get_orient(rf::local_player->cam); + g_spectate_dropped_cameras.push_back(cam); + spectate_save_afl(); + rf::console::print("Dropped spectate camera #{} ({} static cameras available)", + static_cast(g_spectate_dropped_cameras.size()), spectate_static_camera_count()); +} + +// Transition from the current spectate view to `to`, handling target binding/unbinding. +static void spectate_set_view_mode(SpectateViewMode to) +{ + if (!rf::local_player || !rf::local_player->cam) + return; + const SpectateViewMode from = g_spectate_view_mode; + if (from == to) + return; + + const bool from_player = g_spectate_mode_enabled && spectate_is_player_view(from); + const bool to_player = spectate_is_player_view(to); + + if (g_spectate_static_active) + spectate_exit_static(); + g_spectate_freelook_zoom_index = 0; + + if (to_player) { + if (from_player) { + // Switching among player views - keep the same target and binding. + g_spectate_view_mode = to; + spectate_apply_player_view_mode(); } else { - // Saved target is gone, find any player + // Coming from a free view - acquire and bind a target. + rf::Player* resume = (g_spectate_freelook_saved_target + && g_spectate_freelook_saved_target != rf::local_player + && !g_spectate_freelook_saved_target->is_browser) + ? g_spectate_freelook_saved_target + : nullptr; + g_spectate_freelook_saved_target = nullptr; g_spectate_mode_target = rf::local_player; - spectate_next_player(true, true); + g_spectate_view_mode = to; + if (resume) + multi_spectate_set_target_player(resume); + else + spectate_next_player(true, true); + if (g_spectate_mode_enabled) + spectate_apply_player_view_mode(); + else + g_spectate_view_mode = SpectateViewMode::freelook; // no players - fell back to free look } - g_spectate_freelook_saved_target = nullptr; + } + else { + // Entering a free view (free look or static) - release any player target. + if (from_player) { + g_spectate_freelook_saved_target = g_spectate_mode_target; + spectate_unbind_target(); + g_spectate_mode_enabled = false; + g_spectate_mode_target = rf::local_player; + } + if (to == SpectateViewMode::freelook) + multi_spectate_enter_freelook(); + else + spectate_enter_static(); + } +} + +// Attach bind: swap between the attached (player) and detached (free camera) groups, restoring the +// submode last used in the group we're switching to. +void multi_spectate_toggle_attach() +{ + if (!multi_spectate_is_spectating()) + return; + g_spectate_bind_dialog_open = false; // switching groups closes the numpad bind dialog + const bool attached = g_spectate_mode_enabled && spectate_is_player_view(g_spectate_view_mode); + if (attached) { + g_spectate_attached_submode = g_spectate_view_mode; // remember first/third + g_spectate_last_attached = false; + spectate_set_view_mode(g_spectate_detached_submode); // restore free/static + } + else { + g_spectate_detached_submode = g_spectate_static_active + ? SpectateViewMode::static_cam : SpectateViewMode::freelook; // remember free/static + g_spectate_last_attached = true; + g_spectate_third_person_orbit = false; // fresh entry to a player view + spectate_set_view_mode(g_spectate_attached_submode); // restore first/third } } +// Change Spectate View bind: flip the submode within the active group. +void multi_spectate_change_view() +{ + if (!multi_spectate_is_spectating()) + return; + g_spectate_bind_dialog_open = false; // switching submodes closes the numpad bind dialog + const bool attached = g_spectate_mode_enabled && spectate_is_player_view(g_spectate_view_mode); + if (attached) { + const SpectateViewMode to = (g_spectate_view_mode == SpectateViewMode::first_person) + ? SpectateViewMode::third_person : SpectateViewMode::first_person; + if (to == SpectateViewMode::third_person) + g_spectate_third_person_orbit = false; // reset orbit on fresh third-person entry + g_spectate_attached_submode = to; + spectate_set_view_mode(to); + } + else { + const SpectateViewMode to = g_spectate_static_active + ? SpectateViewMode::freelook : SpectateViewMode::static_cam; + if (to == SpectateViewMode::static_cam && spectate_static_camera_count() <= 0) { + rf::console::print("No static cameras on this level. Drop one with [Secondary Attack] in free look."); + return; // stay in free look + } + g_spectate_detached_submode = to; + spectate_set_view_mode(to); + } +} + +// Delete the currently-selected player-dropped static camera (level .rfl cameras are not deletable), +// fix up numpad binds, and advance the view (or fall back to free look if none remain). +static void spectate_delete_current_dropped_camera() +{ + if (!g_spectate_static_active) + return; + const int di = g_spectate_static_index - rf::fixed_camera_count; + if (di < 0) { + // Current camera is a level (.rfl) camera - not deletable. Say so instead of a silent no-op. + rf::console::print("Level cameras cannot be deleted."); + return; + } + if (di >= static_cast(g_spectate_dropped_cameras.size())) + return; // out of range - nothing to delete + const int del_idx = g_spectate_static_index; // absolute index being removed + + g_spectate_dropped_cameras.erase(g_spectate_dropped_cameras.begin() + di); + + // Fix up numpad->camera binds. Dropped-camera absolute indices above del_idx shift down by 1; + // level-camera binds (< fixed_camera_count < del_idx) are unaffected. + for (int k = 0; k < k_spectate_numpad_count; ++k) { + if (g_spectate_static_binds[k] == del_idx) + g_spectate_static_binds[k] = -1; + else if (g_spectate_static_binds[k] > del_idx) + g_spectate_static_binds[k] -= 1; + } + spectate_save_afl(); + + const int total = spectate_static_camera_count(); + if (total <= 0) { + // No cameras left - fall back to free look. + spectate_exit_static(); + g_spectate_detached_submode = SpectateViewMode::freelook; + multi_spectate_enter_freelook(); + rf::console::print("Deleted camera. No static cameras remain; returning to free look."); + return; + } + if (g_spectate_static_index >= total) + g_spectate_static_index = total - 1; // was the last camera; show the new last + // otherwise the same index now shows the next camera that shifted into this slot + spectate_apply_static_index(); + rf::console::print("Deleted camera ({} static cameras remain).", total); +} + +// Per-frame camera positioning for third-person orbit (called from camera_do_frame_hook). Returns +// true if it positioned the camera, so the stock per-frame third-person logic is skipped. +bool multi_spectate_camera_do_frame(rf::Camera* camera) +{ + if (!rf::local_player || camera != rf::local_player->cam || !camera->camera_entity) + return false; + rf::Entity* ce = camera->camera_entity; + + // Static spectate on a player-dropped camera (level .rfl cameras are drawn by the engine's + // fixed view). Position it every frame INCLUDING outside active gameplay - otherwise at round + // end the stock fixed-view path runs for a camera index with no .rfl entry and OOB-reads the + // fixed-camera arrays. + if (g_spectate_static_active && g_spectate_static_index >= rf::fixed_camera_count) { + const int di = g_spectate_static_index - rf::fixed_camera_count; + if (di >= 0 && di < static_cast(g_spectate_dropped_cameras.size())) { + const SpectateStaticCamera& sc = g_spectate_dropped_cameras[di]; + ce->pos = sc.pos; + ce->orient = sc.orient; + ce->eye_pos = sc.pos; + ce->eye_orient = sc.orient; + ce->set_room(nullptr); + ce->update_room(); + return true; + } + } + + // Third-person orbit is only driven during active gameplay; at round end let the engine run + // its own endgame fixed-camera flyby. + if (rf::gameseq_get_state() != rf::GS_GAMEPLAY) + return false; + + if (multi_spectate_is_third_person_orbit()) { + rf::Entity* target = g_spectate_mode_target + ? rf::entity_from_handle(g_spectate_mode_target->entity_handle) + : nullptr; + if (!target) { + // Target dead/gone this frame - drain the mouse accumulator so motion during the dead + // interval doesn't bank up and snap the view on respawn; hold the camera in place. + int mdx = 0, mdy = 0, mdz = 0; + rf::mouse_get_delta(mdx, mdy, mdz); + float dpitch = 0.0f, dyaw = 0.0f; + consume_raw_mouse_deltas(dpitch, dyaw, false); + return true; + } + + // While we're a dead third-person spectator nothing else pumps mouse_get_delta, so the raw + // delta accumulator stays empty. Pump it here, then read it. In Raw/Modern mouse mode the + // hook fills the accumulator (read via consume_raw_mouse_deltas); Classic mode returns the + // raw pixel delta in mdx/mdy instead. + int mdx = 0, mdy = 0, mdz = 0; + rf::mouse_get_delta(mdx, mdy, mdz); + float dpitch = 0.0f, dyaw = 0.0f; + consume_raw_mouse_deltas(dpitch, dyaw, false); + if (dpitch == 0.0f && dyaw == 0.0f && (mdx != 0 || mdy != 0)) { + const float sens = rf::local_player->settings.controls.mouse_sensitivity; + constexpr float classic_scale = 0.0035f; + float fy = static_cast(mdy); + if (rf::local_player->settings.controls.axes[1].invert) + fy = -fy; + dpitch = -fy * sens * classic_scale; + dyaw = static_cast(mdx) * sens * classic_scale; + } + + g_spectate_orbit_yaw += dyaw; + g_spectate_orbit_pitch = std::clamp(g_spectate_orbit_pitch + dpitch, + -k_spectate_orbit_pitch_limit, k_spectate_orbit_pitch_limit); + + rf::Vector3 focus = target->pos; + focus.y += k_spectate_orbit_focus_height; + + // Yaw/pitch parametrize the LOOK direction, exactly like normal mouselook, so the game's + // y-invert (applied in consume_raw_mouse_deltas / the classic fallback) carries through. + // The camera sits `distance` behind the focus along that direction. + rf::Vector3 look_dir{ + std::cos(g_spectate_orbit_pitch) * std::sin(g_spectate_orbit_yaw), + std::sin(g_spectate_orbit_pitch), + std::cos(g_spectate_orbit_pitch) * std::cos(g_spectate_orbit_yaw), + }; + rf::Vector3 cam_pos = focus - look_dir * k_spectate_orbit_distance; + + rf::Matrix3 orient; + orient.make_quick(look_dir); + + ce->pos = cam_pos; + ce->orient = orient; + ce->eye_pos = cam_pos; + ce->eye_orient = orient; + ce->set_room(nullptr); + ce->update_room(); + return true; + } + + return false; +} + rf::Player* multi_spectate_get_target_player() { return g_spectate_mode_target; @@ -322,7 +909,13 @@ rf::Player* multi_spectate_get_target_player() void multi_spectate_leave() { + // Remember which group we were in for next time we enter spectate; orbit never persists. + g_spectate_last_attached = g_spectate_mode_enabled && spectate_is_player_view(g_spectate_view_mode); + g_spectate_third_person_orbit = false; g_spectate_freelook_saved_target = nullptr; + if (g_spectate_static_active) + spectate_exit_static(); + g_spectate_view_mode = SpectateViewMode::first_person; if (g_spectate_mode_enabled) { multi_spectate_set_target_player(nullptr); } else { @@ -342,6 +935,12 @@ void multi_spectate_toggle() else if (rf::player_is_dead(rf::local_player)) { multi_spectate_set_target_player(rf::local_player); spectate_next_player(true, true); + // Restore the group + submode we were last using before leaving spectate. + g_spectate_third_person_orbit = false; + const SpectateViewMode want = g_spectate_last_attached + ? g_spectate_attached_submode : g_spectate_detached_submode; + if (multi_spectate_is_spectating() && g_spectate_view_mode != want) + spectate_set_view_mode(want); } } @@ -351,23 +950,41 @@ bool multi_spectate_execute_action(rf::ControlConfigAction action, bool was_pres return false; } + const bool primary = (action == rf::CC_ACTION_PRIMARY_ATTACK || action == rf::CC_ACTION_SLIDE_RIGHT); + const bool secondary = (action == rf::CC_ACTION_SECONDARY_ATTACK || action == rf::CC_ACTION_SLIDE_LEFT); + if (!primary && !secondary) { + return false; + } + if (g_spectate_mode_enabled) { - if (action == rf::CC_ACTION_PRIMARY_ATTACK || action == rf::CC_ACTION_SLIDE_RIGHT) { - if (was_pressed) - spectate_next_player(true); - return true; // dont allow spawn + // Player views (first / third person / orbit): cycle the spectated player. + // set_target_player re-applies the active view mode, so third person/orbit is preserved. + if (was_pressed) + spectate_next_player(primary); // primary = next, secondary = prev + return true; // dont allow spawn + } + if (multi_spectate_is_static()) { + // Static cameras: cycle through the level's fixed cameras. + if (was_pressed) + spectate_cycle_static_camera(primary); + return true; + } + if (multi_spectate_is_freelook()) { + // Only the literal attack buttons act here; strafe keys must remain free-look movement. + // Primary steps the zoom level (wraps); secondary drops a static camera at this spot. + if (action == rf::CC_ACTION_PRIMARY_ATTACK) { + if (was_pressed) { + constexpr int n = sizeof(k_spectate_freelook_zoom_steps) / sizeof(k_spectate_freelook_zoom_steps[0]); + g_spectate_freelook_zoom_index = (g_spectate_freelook_zoom_index + 1) % n; + } + return true; // consume to prevent respawn } - if (action == rf::CC_ACTION_SECONDARY_ATTACK || action == rf::CC_ACTION_SLIDE_LEFT) { + if (action == rf::CC_ACTION_SECONDARY_ATTACK) { if (was_pressed) - spectate_next_player(false); - return true; - } - } - else if (multi_spectate_is_freelook()) { - // consume attack inputs in freelook to prevent respawn - if (action == rf::CC_ACTION_PRIMARY_ATTACK || action == rf::CC_ACTION_SECONDARY_ATTACK) { - return true; + spectate_drop_freelook_camera(); + return true; // consume to prevent respawn } + return false; // slides fall through to free-look movement } return false; @@ -388,12 +1005,27 @@ void multi_spectate_on_player_kill(rf::Player* victim, rf::Player* killer) void multi_spectate_on_destroy_player(rf::Player* player) { if (player != rf::local_player) { + // Drop any numpad binds pointing at the leaving player so they can't dangle. + for (int i = 0; i < k_spectate_numpad_count; ++i) { + if (g_spectate_player_binds[i] == player) + g_spectate_player_binds[i] = nullptr; + } if (g_spectate_freelook_saved_target == player) g_spectate_freelook_saved_target = nullptr; if (g_spectate_mode_target == player) spectate_next_player(true); - if (g_spectate_mode_target == player) - multi_spectate_set_target_player(nullptr); + if (g_spectate_mode_target == player) { + // The player we were watching left and there's no one else to spectate. Fall back to + // free-look spectate instead of dropping out of spectate. Clear the target directly + // (not via set_target_player, which early-returns under Force Respawn and would leave + // g_spectate_mode_target dangling at the freed player). + spectate_unbind_target(); + g_spectate_mode_enabled = false; + g_spectate_mode_target = rf::local_player; + g_spectate_freelook_saved_target = nullptr; + g_spectate_last_attached = false; + multi_spectate_enter_freelook(); + } } } @@ -403,7 +1035,7 @@ FunHook render_reticle_hook{ [](rf::Player* player) { if (rf::gameseq_get_state() == rf::GS_MULTI_LIMBO) return; - if (g_spectate_mode_enabled) + if (spectate_first_person_render_active()) render_reticle_hook.call_target(g_spectate_mode_target); else render_reticle_hook.call_target(player); @@ -509,6 +1141,16 @@ static ConsoleCommand2 spectate_mode_follow_killer_cmd{ "When a player you're spectating dies, automatically spectate their killer", }; +static ConsoleCommand2 spectate_cameras_cmd{ + "spectate_cameras", + []() { + g_alpine_game_config.spectate_show_camera_meshes = !g_alpine_game_config.spectate_show_camera_meshes; + rf::console::print("Static camera meshes in free look spectate are {}", + g_alpine_game_config.spectate_show_camera_meshes ? "shown" : "hidden"); + }, + "Toggles showing camera meshes at static camera locations while free look spectating", +}; + // gameplay_render_frame checks scanning_for_target at 0x00431CCC for FOV and scanner overlay // rendering, BEFORE player_render_new (0x0043285D) runs. The game loop clears scanning_for_target // on local_player each frame (FUN_004ad410) because local_player isn't holding the rail driver. @@ -560,7 +1202,7 @@ static FunHook entity_state_flags_sync_hook{ static CodeInjection gameplay_render_frame_early_scanner_sync{ 0x00431CCC, []() { - if (g_spectate_mode_enabled && rf::local_player && g_spectate_mode_target) { + if (spectate_first_person_render_active() && rf::local_player && g_spectate_mode_target) { // The receiving-side injection sets scanning_for_target directly on the target. // Copy it to local_player so gameplay_render_frame's scanner overlay code sees it. bool scanning = g_spectate_mode_target->fpgun_data.scanning_for_target; @@ -575,7 +1217,7 @@ static CodeInjection gameplay_render_frame_early_scanner_sync{ static CodeInjection gameplay_render_frame_skip_scope_when_scanning{ 0x00431D1C, [](auto& regs) { - if (g_spectate_mode_enabled && rf::local_player && + if (spectate_first_person_render_active() && rf::local_player && rf::local_player->fpgun_data.scanning_for_target) { regs.edi = 0; } @@ -589,13 +1231,309 @@ static CodeInjection gameplay_render_frame_skip_scope_when_scanning{ static CodeInjection gameplay_render_frame_spectate_hud_over_scanner{ 0x00432A20, []() { - if (g_spectate_mode_enabled && rf::local_player && + if (spectate_first_person_render_active() && rf::local_player && rf::local_player->fpgun_data.scanning_for_target) { multi_spectate_render(); } }, }; +static void spectate_populate_default_binds() +{ + // Re-seed whenever no numpad player binds are set - covers the first spectate of a session and a + // later server whose binds were cleared on join. Never clobbers binds the user has set. + for (int i = 0; i < k_spectate_numpad_count; ++i) { + if (g_spectate_player_binds[i]) + return; + } + + // Seed numpad 0-9 with the current top-scoring spectatable players (highest first). + std::vector players; + for (rf::Player& p : SinglyLinkedList{rf::player_list}) { + if (&p == rf::local_player || p.is_browser || !p.stats) { + continue; + } + players.push_back(&p); + } + std::sort(players.begin(), players.end(), [](const rf::Player* a, const rf::Player* b) { + return a->stats->score > b->stats->score; + }); + const int n = std::min(k_spectate_numpad_count, static_cast(players.size())); + for (int i = 0; i < n; ++i) { + g_spectate_player_binds[i] = players[i]; + } +} + +static bool spectate_project_to_screen(const rf::Vector3& world_pos, float& sx, float& sy) +{ + rf::gr::Vertex v{}; + if (!rf::gr::rotate_vertex(&v, &world_pos)) { // 0 => in front of the camera + rf::gr::project_vertex(&v); + if (v.flags & rf::gr::VF_PROJECTED) { + sx = v.sx; + sy = v.sy; + return true; + } + } + return false; +} + +// Returns the numpad bind suffix for a player for the nameplate, e.g. " (1, 3)" (or "" if none). +static std::string spectate_player_bind_suffix(const rf::Player* player) +{ + std::string keys; + for (int k = 0; k < k_spectate_numpad_count; ++k) { + if (g_spectate_player_binds[k] == player) { + if (!keys.empty()) { + keys += ", "; + } + keys += std::to_string(k); + } + } + return keys.empty() ? std::string{} : (" (" + keys + ")"); +} + +// Returns the numpad key(s) bound to the given static camera index (e.g. "3", or "" if none). +static std::string spectate_static_bind_label(int static_index) +{ + std::string label; + for (int k = 0; k < k_spectate_numpad_count; ++k) { + if (g_spectate_static_binds[k] == static_index) { + if (!label.empty()) { + label += ","; + } + label += std::to_string(k); + } + } + return label; +} + +static void spectate_render_camera_mesh(const rf::Vector3& pos, const rf::Matrix3& orient, + int static_index, const rf::Vector3& eye) +{ + rf::MeshRenderParams params{}; + params.init_defaults(); + params.orient = orient; + rf::Vector3 p = pos; + rf::Matrix3 o = orient; + rf::vmesh_render(g_spectate_camera_mesh, &p, &o, ¶ms); + + // Draw the numpad bind number above the mesh so it's clear which camera is bound to which key. + const std::string label = spectate_static_bind_label(static_index); + if (label.empty()) { + return; + } + rf::Vector3 label_pos = pos; + label_pos.y += 0.6f; + + // Occlude the label like normal geometry: skip it if level solid blocks the line from the + // viewer to it. (The mesh itself is already depth-tested by the renderer.) + rf::Vector3 trace_start = eye; + rf::Vector3 trace_end = label_pos; + rf::GCollisionOutput col{}; + if (rf::collide_linesegment_level_solid(trace_start, trace_end, + rf::CF_ANY_HIT | rf::CF_PROCESS_INVISIBLE_FACES, &col)) { + return; // occluded + } + + float sx = 0.0f, sy = 0.0f; + if (spectate_project_to_screen(label_pos, sx, sy)) { + rf::gr::set_color(0xFF, 0xF0, 0x50, 0xFF); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, static_cast(sx), static_cast(sy), + label.c_str(), hud_get_default_font()); + } +} + +// Draw a marker mesh at each static camera location (level + player-dropped) so free look +// spectators can see where the static cameras are. Injected right after the world render in +// gameplay_render_frame (0x00431FF8 CALL scene render) while the 3D pipeline is still active. +// The mesh is loaded in the logic pass (multi_spectate_process_bind_input), NOT here - loading a +// mesh + its textures mid-render can corrupt the bitmap system and crash on shutdown. +static CodeInjection spectate_render_camera_meshes_patch{ + 0x00432000, + []() { + if (!g_alpine_game_config.spectate_show_camera_meshes || !multi_spectate_is_freelook() + || spectate_static_camera_count() <= 0 || !g_spectate_camera_mesh + || !rf::local_player || !rf::local_player->cam) { + return; + } + const rf::Vector3 eye = rf::camera_get_pos(rf::local_player->cam); + for (int i = 0; i < rf::fixed_camera_count; ++i) { + spectate_render_camera_mesh(*rf::fixed_camera_get_pos(i), *rf::fixed_camera_get_orient(i), i, eye); + } + for (int j = 0; j < static_cast(g_spectate_dropped_cameras.size()); ++j) { + const SpectateStaticCamera& sc = g_spectate_dropped_cameras[j]; + spectate_render_camera_mesh(sc.pos, sc.orient, rf::fixed_camera_count + j, eye); + } + }, +}; + +// Poll the numpad keys each frame while spectating: Numpad Enter toggles the bind dialog; numpad +// numbers bind (dialog open) or jump to (dialog closed) a player/camera. Called from key.cpp. +void multi_spectate_process_bind_input() +{ + if (!rf::is_multi || !multi_spectate_is_spectating()) { + return; + } + + // Preload the camera marker mesh here (logic pass) rather than in the render injection - loading + // a mesh + its textures mid-render is bitmap-creation-crash-prone (see the gr_d3d11_hooks note), + // and manifested as a bitmap double-free on game exit. + if (!g_spectate_camera_mesh_load_attempted && g_alpine_game_config.spectate_show_camera_meshes + && multi_spectate_is_freelook() && spectate_static_camera_count() > 0) { + g_spectate_camera_mesh_load_attempted = true; + g_spectate_camera_mesh = rf::vmesh_load("camera_icon1.v3m", rf::MESH_TYPE_STATIC, -1); + } + + const bool player_mode = g_spectate_mode_enabled; // first/third person + const bool static_mode = g_spectate_static_active; // static cameras + const bool bindable = player_mode || static_mode; + // Still consume the numpad counters while typing (so they don't queue up), but don't act on + // them - the console/chat-say box uses the separate character buffer for typing. + const bool typing = rf::console::console_is_visible() || rf::multi_chat_is_say_visible(); + + if (rf::key_get_and_reset_down_counter(rf::KEY_PADENTER) > 0 && !typing) { + g_spectate_bind_dialog_open = bindable && !g_spectate_bind_dialog_open; + } + // Esc cancels/closes the bind dialog (only read its edge while the dialog is open and we're not + // typing, so it doesn't swallow Esc anywhere else). + if (g_spectate_bind_dialog_open && !typing + && rf::key_get_and_reset_down_counter(rf::KEY_ESC) > 0) { + g_spectate_bind_dialog_open = false; + } + if (!bindable) { + g_spectate_bind_dialog_open = false; + } + + // Middle mouse toggles third-person orbit (attached third person only). mouse_was_button_pressed + // is a pure read (the engine refreshes the per-frame button snapshot itself), so this is one + // toggle per physical click. + const bool mmb_pressed = rf::mouse_was_button_pressed(2) > 0; + if (mmb_pressed && !typing && g_spectate_mode_enabled + && g_spectate_view_mode == SpectateViewMode::third_person) { + g_spectate_third_person_orbit = !g_spectate_third_person_orbit; + spectate_apply_player_view_mode(); + } + + // Delete removes the current player-dropped static camera (static mode only). It can flip + // g_spectate_static_active off (last camera deleted), which would leave the captured + // player_mode/static_mode flags stale, so return afterwards instead of falling into the numpad + // loop this frame. + if (rf::key_get_and_reset_down_counter(rf::KEY_DELETE) > 0 && static_mode && !typing) { + spectate_delete_current_dropped_camera(); + return; + } + + for (int i = 0; i < k_spectate_numpad_count; ++i) { + if (rf::key_get_and_reset_down_counter(g_spectate_numpad_keys[i]) <= 0 || typing) { + continue; + } + if (g_spectate_bind_dialog_open) { + // Bind the current player/camera to this key, then close the dialog. + if (player_mode && g_spectate_mode_target && g_spectate_mode_target != rf::local_player) { + g_spectate_player_binds[i] = g_spectate_mode_target; + } + else if (static_mode) { + g_spectate_static_binds[i] = g_spectate_static_index; + spectate_save_afl(); // persist the new numpad->camera bind for this level + } + g_spectate_bind_dialog_open = false; + } + else if (player_mode) { + rf::Player* p = g_spectate_player_binds[i]; + if (p && p != rf::local_player) { + multi_spectate_set_target_player(p); + } + } + else if (static_mode) { + const int idx = g_spectate_static_binds[i]; + if (idx >= 0 && idx < spectate_static_camera_count()) { + g_spectate_static_index = idx; + spectate_apply_static_index(); + } + } + } +} + +static void spectate_render_bind_dialog() +{ + const bool player_mode = g_spectate_mode_enabled; + const bool static_mode = g_spectate_static_active; + if (!g_spectate_bind_dialog_open || (!player_mode && !static_mode)) { + return; + } + + const int font = hud_get_default_font(); + const int font_h = rf::gr::get_font_height(font); + const int scr_w = rf::gr::screen_width(); + const int scr_h = rf::gr::screen_height(); + + const std::string title = std::string("Press a numpad number key to bind to this ") + + (player_mode ? "player" : "camera"); + const std::string subtitle = "Press NUMPAD ENTER to cancel"; + + // Build the value strings up front so the box can be sized to fit the widest content. + std::string values[k_spectate_numpad_count]; + bool bound[k_spectate_numpad_count]; + for (int i = 0; i < k_spectate_numpad_count; ++i) { + if (player_mode) { + rf::Player* p = g_spectate_player_binds[i]; + bound[i] = (p != nullptr); + values[i] = bound[i] ? p->name.c_str() : "(unbound)"; + } + else { + const int idx = g_spectate_static_binds[i]; + bound[i] = (idx >= 0 && idx < spectate_static_camera_count()); + values[i] = bound[i] ? ("Camera " + std::to_string(idx + 1)) : "(unbound)"; + } + } + + auto str_w = [&](const char* s) { + auto [w, h] = rf::gr::get_string_size(s, font); + return w; + }; + + const int col_gap = 24; + const int label_w = str_w("NUM 0"); + int max_value_w = 0; + for (const std::string& v : values) { + max_value_w = std::max(max_value_w, str_w(v.c_str())); + } + const int row_w = label_w + col_gap + max_value_w; + + const int pad = 20; + const int content_w = std::max({str_w(title.c_str()), str_w(subtitle.c_str()), row_w}); + const int box_w = std::min(scr_w - 20, content_w + pad * 2); + + const int line_h = font_h + 3; + const int box_h = pad + font_h * 2 + 12 + line_h * 10 + pad; + const int box_x = (scr_w - box_w) / 2; + const int box_y = (scr_h - box_h) / 2; + + rf::gr::set_color(0, 0, 0, 190); + rf::gr::rect(box_x, box_y, box_w, box_h); + + const int cx = box_x + box_w / 2; + int y = box_y + pad; + rf::gr::set_color(0xFF, 0xFF, 0xFF, 0xFF); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, cx, y, title.c_str(), font); + y += font_h; + rf::gr::set_color(0xFF, 0xFF, 0xFF, 0xB0); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, cx, y, subtitle.c_str(), font); + y += font_h + 12; + + // Center the fixed-width rows block within the box. + const int label_x = box_x + (box_w - row_w) / 2; + const int value_x = label_x + label_w + col_gap; + for (int i = 0; i < k_spectate_numpad_count; ++i) { + rf::gr::set_color(0xFF, 0xFF, 0xFF, bound[i] ? 0xFF : 0x60); + const std::string key_label = "NUM " + std::to_string(i); + rf::gr::string(label_x, y, key_label.c_str(), font); + rf::gr::string(value_x, y, values[i].c_str(), font); + y += line_h; + } +} + #if SPECTATE_MODE_SHOW_WEAPON // Hook entity_play_attack_anim (0x0042C3C0) — called from the obj_update processing path @@ -619,7 +1557,7 @@ FunHook entity_play_attack_anim_spectate_hook{ static void player_render_new(rf::Player* player) { - if (g_spectate_mode_enabled) { + if (spectate_first_person_render_active()) { rf::Entity* entity = rf::entity_from_handle(g_spectate_mode_target->entity_handle); // HACKFIX: RF uses function player_fpgun_set_remote_charge_visible for local player only @@ -729,7 +1667,7 @@ static void player_render_new(rf::Player* player) CallHook gameplay_render_frame_player_fpgun_get_zoom_hook{ 0x00431B6D, [](rf::Player* pp) { - if (g_spectate_mode_enabled) { + if (spectate_first_person_render_active()) { // Rail driver scanner has its own FOV (set via the scanning_for_target path). // Return 0 so the sniper scope overlay doesn't render. if (g_spectate_mode_target->fpgun_data.scanning_for_target) { @@ -749,7 +1687,7 @@ CallHook gameplay_render_frame_player_fpgun_get_zoom_hook{ static FunHook render_to_dynamic_textures_hook{ 0x00431820, []() { - if (g_spectate_mode_enabled && rf::local_player && g_spectate_mode_target) { + if (spectate_first_person_render_active() && rf::local_player && g_spectate_mode_target) { // The receiving-side injection sets scanning_for_target directly on the target. // Copy it to local_player so render_to_dynamic_textures renders the scanner texture. bool scanning = g_spectate_mode_target->fpgun_data.scanning_for_target; @@ -807,6 +1745,8 @@ void multi_spectate_appy_patch() spectate_cmd.register_cmd(); spectate_mode_minimal_ui_cmd.register_cmd(); spectate_mode_follow_killer_cmd.register_cmd(); + spectate_cameras_cmd.register_cmd(); + spectate_render_camera_meshes_patch.install(); // Handle scanner state in entity state flags (both sending and receiving) entity_state_flags_sync_hook.install(); @@ -842,19 +1782,19 @@ void multi_spectate_after_full_game_init() { g_spectate_mode_target = rf::local_player; player_fpgun_set_player(rf::local_player); + // New game/server session: drop any numpad player binds from a previous server so they can't + // dangle, and allow the defaults to re-seed for this server (see spectate_populate_default_binds). + for (int i = 0; i < k_spectate_numpad_count; ++i) + g_spectate_player_binds[i] = nullptr; } -void multi_spectate_player_create_entity_post(rf::Player* player, rf::Entity* entity) +void multi_spectate_player_create_entity_post(rf::Player* player, [[maybe_unused]] rf::Entity* entity) { - // hide target player from camera after respawn + // Re-apply the active view mode after the target respawns (the game may have switched our + // camera to fixed when it entered limbo). This hides the body + shows the weapon in first + // person, or shows the body in third person / orbit. if (g_spectate_mode_enabled && player == g_spectate_mode_target) { - entity->local_player = player; - // When entering limbo state the game changes camera mode to fixed - // Make sure we are in first person mode when target entity spawns - rf::Camera* cam = rf::local_player->cam; - if (cam->mode != rf::CAMERA_FIRST_PERSON) { - rf::camera_enter_first_person(cam); - } + spectate_apply_player_view_mode(); } // Do not allow spectating in Force Respawn game after spawning for the first time if (player == rf::local_player) { @@ -866,6 +1806,23 @@ void multi_spectate_level_init() { g_spawned_in_current_level = false; g_spectate_freelook_saved_target = nullptr; + g_spectate_view_mode = SpectateViewMode::first_person; + g_spectate_third_person_orbit = false; + g_spectate_static_active = false; + g_spectate_freelook_zoom_index = 0; + g_spectate_static_index = 0; + g_spectate_dropped_cameras.clear(); // dropped cameras are level-specific + // The vmesh handle is freed on level unload; force a reload on the next use. + g_spectate_camera_mesh = nullptr; + g_spectate_camera_mesh_load_attempted = false; + // Static camera binds are level-specific and reset here; player binds intentionally persist + // across map changes (players stay connected - disconnects are handled in on_destroy_player). + g_spectate_bind_dialog_open = false; + for (int i = 0; i < k_spectate_numpad_count; ++i) { + g_spectate_static_binds[i] = -1; + } + // Restore this level's persisted dropped cameras and numpad binds (if a save file exists). + spectate_load_afl(); } template @@ -975,6 +1932,19 @@ static int spectate_raise_hints_above_hud(int hints_y, int line_count, int line_ return hints_y; } +// Draw a column of bind/label hint rows (bind right-aligned at left_x, label at right_x). +static void draw_spectate_hints(const std::pair* hints, int count, + int left_x, int right_x, int y, int font, int font_h) +{ + for (int i = 0; i < count; ++i) { + rf::gr::set_color(0xFF, 0xFF, 0xFF, 0xC0); + rf::gr::string_aligned(rf::gr::ALIGN_RIGHT, left_x, y, hints[i].first, font); + rf::gr::set_color(0xFF, 0xFF, 0xFF, 0x80); + rf::gr::string(right_x, y, hints[i].second, font); + y += font_h; + } +} + void multi_spectate_render() { if (is_hud_effectively_hidden() || rf::gameseq_get_state() != rf::GS_GAMEPLAY) @@ -982,6 +1952,75 @@ void multi_spectate_render() { return; } + // Draw the numpad quick-bind dialog (if open). It doesn't replace the mode HUD - the spectate + // nameplate stays visible underneath/around it (the two don't overlap on screen). + spectate_render_bind_dialog(); + + if (multi_spectate_is_static()) { + if (!g_alpine_game_config.spectate_mode_minimal_ui && !g_remote_server_cfg_popup.is_active()) { + int medium_font = hud_get_default_font(); + int medium_font_h = rf::gr::get_font_height(medium_font); + int large_font = hud_get_large_font(); + int scr_w = rf::gr::screen_width(); + int scr_h = rf::gr::screen_height(); + + rf::Color white_clr{255, 255, 255, 255}; + rf::Color shadow_clr{0, 0, 0, 128}; + + int title_x = scr_w / 2; + int title_y = g_alpine_game_config.big_hud ? 250 : 150; + draw_with_shadow( + title_x, title_y, 2, 2, white_clr, shadow_clr, + [=](int x, int y) { + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, x, y, "STATIC CAMERA", large_font); + } + ); + + // Current-camera subtitle just below the title. + int large_font_h = rf::gr::get_font_height(large_font); + std::string cam_subtitle = "Camera " + std::to_string(g_spectate_static_index + 1) + + " of " + std::to_string(spectate_static_camera_count()); + if (g_spectate_static_index >= rf::fixed_camera_count) + cam_subtitle += " (dropped)"; + rf::gr::set_color(0xFF, 0xFF, 0xFF, 0xB0); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, title_x, title_y + large_font_h, + cam_subtitle.c_str(), medium_font); + + int hints_y = scr_h - (g_alpine_game_config.big_hud ? 200 : 120) + medium_font_h * 2; + int hints_left_x = g_alpine_game_config.big_hud ? 120 : 70; + int hints_right_x = g_alpine_game_config.big_hud ? 140 : 80; + + std::string attach_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH)); + std::string change_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_CHANGE_VIEW)); + std::string exit_spec_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE)); + std::string spec_menu_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_MENU)); + std::string next_cam_text = + get_action_bind_name(rf::ControlConfigAction::CC_ACTION_PRIMARY_ATTACK); + std::string prev_cam_text = + get_action_bind_name(rf::ControlConfigAction::CC_ACTION_SECONDARY_ATTACK); + + std::pair hints[12]; + int nh = 0; + hints[nh++] = {attach_text.c_str(), "Attach to Player"}; + hints[nh++] = {change_text.c_str(), "Free / Static Camera"}; + hints[nh++] = {next_cam_text.c_str(), "Next Camera"}; + hints[nh++] = {prev_cam_text.c_str(), "Previous Camera"}; + if (g_spectate_static_index >= rf::fixed_camera_count) + hints[nh++] = {"DEL", "Delete This Camera"}; + hints[nh++] = {"NUM 0-9", "Jump to Camera"}; + hints[nh++] = {"NUM ENTER", "Camera Quick-Binds"}; + hints[nh++] = {spec_menu_text.c_str(), "Open Spectate Options Menu"}; + hints[nh++] = {exit_spec_text.c_str(), "Exit Spectate Mode"}; + hints_y = spectate_raise_hints_above_hud(hints_y, nh, medium_font_h); + draw_spectate_hints(hints, nh, hints_left_x, hints_right_x, hints_y, medium_font, medium_font_h); + } + return; + } + if (multi_spectate_is_freelook()) { if (!g_alpine_game_config.spectate_mode_minimal_ui && !g_remote_server_cfg_popup.is_active()) { int medium_font = hud_get_default_font(); @@ -1005,8 +2044,11 @@ void multi_spectate_render() { int hints_left_x = g_alpine_game_config.big_hud ? 120 : 70; int hints_right_x = g_alpine_game_config.big_hud ? 140 : 80; - std::string toggle_freelook_text = get_action_bind_name( - get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK) + std::string attach_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH) + ); + std::string change_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_CHANGE_VIEW) ); std::string exit_spec_text = get_action_bind_name( get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE) @@ -1015,21 +2057,22 @@ void multi_spectate_render() { get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_MENU) ); - const char* hints[][2] = { - {toggle_freelook_text.c_str(), "Switch to First Person"}, - {spec_menu_text.c_str(), "Open Spectate Options Menu"}, - {exit_spec_text.c_str(), "Exit Spectate Mode"}, - }; + std::string zoom_text = + get_action_bind_name(rf::ControlConfigAction::CC_ACTION_PRIMARY_ATTACK); + std::string drop_text = + get_action_bind_name(rf::ControlConfigAction::CC_ACTION_SECONDARY_ATTACK); + + std::pair hints[12]; + int nh = 0; + hints[nh++] = {attach_text.c_str(), "Attach to Player"}; + hints[nh++] = {change_text.c_str(), "Free / Static Camera"}; + hints[nh++] = {zoom_text.c_str(), "Zoom"}; + hints[nh++] = {drop_text.c_str(), "Drop Camera"}; + hints[nh++] = {spec_menu_text.c_str(), "Open Spectate Options Menu"}; + hints[nh++] = {exit_spec_text.c_str(), "Exit Spectate Mode"}; int hints_y = scr_h - (g_alpine_game_config.big_hud ? 200 : 120) + medium_font_h * 2; - hints_y = spectate_raise_hints_above_hud( - hints_y, static_cast(sizeof(hints) / sizeof(hints[0])), medium_font_h); - for (auto& hint : hints) { - rf::gr::set_color(0xFF, 0xFF, 0xFF, 0xC0); - rf::gr::string_aligned(rf::gr::ALIGN_RIGHT, hints_left_x, hints_y, hint[0], medium_font); - rf::gr::set_color(0xFF, 0xFF, 0xFF, 0x80); - rf::gr::string(hints_right_x, hints_y, hint[1], medium_font); - hints_y += medium_font_h; - } + hints_y = spectate_raise_hints_above_hud(hints_y, nh, medium_font_h); + draw_spectate_hints(hints, nh, hints_left_x, hints_right_x, hints_y, medium_font, medium_font_h); } return; } @@ -1123,11 +2166,22 @@ void multi_spectate_render() { } ); + // Current-view subtitle just below the title. + const char* view_subtitle = g_spectate_view_mode == SpectateViewMode::first_person + ? "First Person" + : (g_spectate_third_person_orbit ? "Third Person (Orbit)" : "Third Person"); + rf::gr::set_color(0xFF, 0xFF, 0xFF, 0xB0); + rf::gr::string_aligned(rf::gr::ALIGN_CENTER, title_x, title_y + large_font_h, + view_subtitle, medium_font); + if (!g_remote_server_cfg_popup.is_active()) { int hints_left_x = g_alpine_game_config.big_hud ? 120 : 70; int hints_right_x = g_alpine_game_config.big_hud ? 140 : 80; - std::string toggle_freelook_text = get_action_bind_name( - get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK) + std::string attach_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH) + ); + std::string change_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_CHANGE_VIEW) ); std::string exit_spec_text = get_action_bind_name( get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE) @@ -1135,34 +2189,32 @@ void multi_spectate_render() { std::string spec_menu_text = get_action_bind_name( get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_MENU) ); - const char* hints[][2] = { - {toggle_freelook_text.c_str(), "Switch to Freelook"}, - {spec_menu_text.c_str(), "Open Spectate Options Menu"}, - {exit_spec_text.c_str(), "Exit Spectate Mode"}, - }; + std::string next_player_text = + get_action_bind_name(rf::ControlConfigAction::CC_ACTION_PRIMARY_ATTACK); + + std::pair hints[12]; + int nh = 0; + hints[nh++] = {attach_text.c_str(), "Detach Camera"}; + hints[nh++] = {change_text.c_str(), "First / Third Person View"}; + hints[nh++] = {next_player_text.c_str(), "Next / Previous Player"}; + if (g_spectate_view_mode == SpectateViewMode::third_person) + hints[nh++] = {"MOUSE 3", "Toggle Camera Orbit"}; + hints[nh++] = {"NUM 0-9", "Jump to Player"}; + hints[nh++] = {"NUM ENTER", "Player Quick-Binds"}; + hints[nh++] = {spec_menu_text.c_str(), "Open Spectate Options Menu"}; + hints[nh++] = {exit_spec_text.c_str(), "Exit Spectate Mode"}; int hints_y = scr_h - (g_alpine_game_config.big_hud ? 200 : 120) + medium_font_h * 2; - hints_y = spectate_raise_hints_above_hud( - hints_y, static_cast(sizeof(hints) / sizeof(hints[0])), medium_font_h); - for (auto& hint : hints) { - rf::gr::set_color(0xFF, 0xFF, 0xFF, 0xC0); - rf::gr::string_aligned( - rf::gr::ALIGN_RIGHT, - hints_left_x, - hints_y, - hint[0], - medium_font - ); - rf::gr::set_color(0xFF, 0xFF, 0xFF, 0x80); - rf::gr::string(hints_right_x, hints_y, hint[1], medium_font); - hints_y += medium_font_h; - } + hints_y = spectate_raise_hints_above_hud(hints_y, nh, medium_font_h); + draw_spectate_hints(hints, nh, hints_left_x, hints_right_x, hints_y, medium_font, medium_font_h); } } int small_font = hud_get_small_font(); int small_font_h = rf::gr::get_font_height(small_font); - const char* target_name = g_spectate_mode_target->name; + const std::string target_name_str = std::string(g_spectate_mode_target->name.c_str()) + + spectate_player_bind_suffix(g_spectate_mode_target); + const char* target_name = target_name_str.c_str(); const char* spectating_label = "Spectating:"; const auto [spectating_label_w, spectating_label_h] = rf::gr::get_string_size(spectating_label, small_font); diff --git a/game_patch/hud/multi_spectate.h b/game_patch/hud/multi_spectate.h index b25ba494..eeff9706 100644 --- a/game_patch/hud/multi_spectate.h +++ b/game_patch/hud/multi_spectate.h @@ -6,6 +6,7 @@ namespace rf { struct Player; struct Entity; + struct Camera; } void multi_spectate_set_target_player(rf::Player* player); @@ -19,10 +20,17 @@ void multi_spectate_on_destroy_player(rf::Player* player); void multi_spectate_player_create_entity_post(rf::Player* player, rf::Entity* entity); bool multi_spectate_is_spectating(); bool multi_spectate_is_first_person(); +bool multi_spectate_is_following_player(); void multi_spectate_enter_freelook(); -void multi_spectate_toggle_freelook(); +void multi_spectate_toggle_attach(); +void multi_spectate_change_view(); void multi_spectate_toggle(); bool multi_spectate_is_freelook(); +bool multi_spectate_is_static(); +bool multi_spectate_is_third_person_orbit(); +float multi_spectate_get_view_fov_scale(); +bool multi_spectate_camera_do_frame(rf::Camera* camera); bool multi_spectate_execute_action(rf::ControlConfigAction action, bool was_pressed); +void multi_spectate_process_bind_input(); void multi_spectate_sync_crouch_anim(); void multi_spectate_on_obj_update_fire(rf::Entity* entity, bool alt_fire); diff --git a/game_patch/input/key.cpp b/game_patch/input/key.cpp index f501ff0e..68211a22 100644 --- a/game_patch/input/key.cpp +++ b/game_patch/input/key.cpp @@ -275,10 +275,12 @@ CodeInjection control_config_init_patch{ rf::AlpineControlConfigAction::AF_ACTION_REMOTE_SERVER_CFG); alpine_control_config_add_item(ccp, "Inspect Weapon", false, rf::KEY_I, -1, -1, rf::AlpineControlConfigAction::AF_ACTION_INSPECT_WEAPON); - alpine_control_config_add_item(ccp, "Cycle Spectate Modes", false, rf::KEY_PERIOD, -1, -1, - rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK); + alpine_control_config_add_item(ccp, "Attach Spectate Camera", false, rf::KEY_PERIOD, -1, -1, + rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH); alpine_control_config_add_item(ccp, "Toggle Spectate", false, rf::KEY_DIVIDE, -1, -1, rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE); + alpine_control_config_add_item(ccp, "Change Spectate View", false, rf::KEY_SEMICOL, -1, -1, + rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_CHANGE_VIEW); alpine_control_config_add_item(ccp, "Spray", 0, rf::KEY_Z, -1, -1, rf::AlpineControlConfigAction::AF_ACTION_SPRAY); }, @@ -436,10 +438,15 @@ CodeInjection player_execute_action_patch3{ && is_server_minimum_af_version(1, 2)) { g_remote_server_cfg_popup.toggle(); } else if (alpine_action_index - == static_cast(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK) + == static_cast(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_ATTACH) && !rf::is_dedicated_server && multi_spectate_is_spectating()) { - multi_spectate_toggle_freelook(); + multi_spectate_toggle_attach(); + } else if (alpine_action_index + == static_cast(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_CHANGE_VIEW) + && !rf::is_dedicated_server + && multi_spectate_is_spectating()) { + multi_spectate_change_view(); } else if (alpine_action_index == static_cast(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE) && !rf::is_dedicated_server) { @@ -467,6 +474,9 @@ CodeInjection controls_process_patch{ CodeInjection controls_process_chat_menu_patch{ 0x00430E19, [](auto& regs) { + // Spectate numpad binds. + multi_spectate_process_bind_input(); + const bool chat_menu_numeric_capture_active = get_chat_menu_is_active() && !rf::console::console_is_visible() diff --git a/game_patch/input/mouse.cpp b/game_patch/input/mouse.cpp index bd5cdd1f..96156989 100644 --- a/game_patch/input/mouse.cpp +++ b/game_patch/input/mouse.cpp @@ -13,6 +13,7 @@ #include "../rf/player/camera.h" #include "../misc/alpine_settings.h" #include "../main/main.h" +#include "../hud/multi_spectate.h" #include "mouse.h" #include "../multi/multi.h" #include "input.h" @@ -236,9 +237,10 @@ FunHook mouse_get_delta_hook{ } // If the player entity is not valid (dead/spawn transition), pause raw delta. - // Exception: spectator freelook camera should still receive mouse input. + // Exception: the spectator freelook camera and third-person orbit spectate both drive + // the camera with mouse input, so let their deltas through. if (!rf::local_player_entity || rf::entity_is_dying(rf::local_player_entity)) { - if (!is_freelook_camera()) { + if (!is_freelook_camera() && !multi_spectate_is_third_person_orbit()) { reset_mouse_delta_accumulators(); dx = 0; dy = 0; diff --git a/game_patch/misc/alpine_settings.cpp b/game_patch/misc/alpine_settings.cpp index 46849b7b..6078b68a 100644 --- a/game_patch/misc/alpine_settings.cpp +++ b/game_patch/misc/alpine_settings.cpp @@ -374,6 +374,10 @@ bool alpine_player_settings_load(rf::Player* player) g_alpine_game_config.spectate_mode_minimal_ui = std::stoi(settings["SpectateMinimalUI"]); processed_keys.insert("SpectateMinimalUI"); } + if (settings.count("SpectateShowCameras")) { + g_alpine_game_config.spectate_show_camera_meshes = std::stoi(settings["SpectateShowCameras"]); + processed_keys.insert("SpectateShowCameras"); + } if (settings.count("ShowFPS")) { g_alpine_game_config.fps_counter = std::stoi(settings["ShowFPS"]); processed_keys.insert("ShowFPS"); @@ -1312,6 +1316,7 @@ void alpine_player_settings_save(rf::Player* player) file << "ExplosionFlashLightsEnv=" << g_alpine_game_config.explosion_env_flash_lights << "\n"; file << "BurningEntityLights=" << g_alpine_game_config.burning_entity_lights << "\n"; file << "SpectateMinimalUI=" << g_alpine_game_config.spectate_mode_minimal_ui << "\n"; + file << "SpectateShowCameras=" << g_alpine_game_config.spectate_show_camera_meshes << "\n"; file << "ShowFPS=" << g_alpine_game_config.fps_counter << "\n"; file << "ShowSpeed=" << g_alpine_game_config.speed_display << "\n"; file << "FPSCounterAverageMs=" << g_alpine_game_config.fps_counter_average_ms << "\n"; diff --git a/game_patch/misc/alpine_settings.h b/game_patch/misc/alpine_settings.h index 2654d398..240941dd 100644 --- a/game_patch/misc/alpine_settings.h +++ b/game_patch/misc/alpine_settings.h @@ -157,6 +157,7 @@ struct AlpineGameSettings bool speed_display = false; bool ping_display = true; bool spectate_mode_minimal_ui = false; + bool spectate_show_camera_meshes = true; // draw camera meshes in free look bool save_console_history = false; // checked before config loaded, must be false here bool screen_shake_force_off = false; bool display_target_player_names = true; diff --git a/game_patch/misc/camera.cpp b/game_patch/misc/camera.cpp index ddc03bb6..0d0e1f3c 100644 --- a/game_patch/misc/camera.cpp +++ b/game_patch/misc/camera.cpp @@ -520,6 +520,10 @@ FunHook camera_do_frame_hook{ // Disengage and fall back to stock behaviour. g_static_camera_mode = AlpineStaticCameraMode::None; } + // Third-person orbit spectate positions the camera itself each frame. + if (multi_spectate_camera_do_frame(camera)) { + return; + } camera_do_frame_hook.call_target(camera); }, }; diff --git a/game_patch/multi/kill.cpp b/game_patch/multi/kill.cpp index 2ef13a51..e620a2f3 100644 --- a/game_patch/multi/kill.cpp +++ b/game_patch/multi/kill.cpp @@ -418,7 +418,7 @@ void print_kill_message(rf::Player* killed_player, rf::Player* killer_player) msg = rf::String::format("{}{}!", mui_msg, killed_player->name); } else { - rf::Player* spectate_target = multi_spectate_is_first_person() ? multi_spectate_get_target_player() : nullptr; + rf::Player* spectate_target = multi_spectate_is_following_player() ? multi_spectate_get_target_player() : nullptr; color_id = (killed_player == spectate_target || killer_player == spectate_target) ? rf::ChatMsgColor::white_white : rf::ChatMsgColor::default_; if (killer_player == killed_player) { @@ -439,7 +439,7 @@ void print_kill_message(rf::Player* killed_player, rf::Player* killer_player) if (g_alpine_game_config.killfeed_enabled) { bool is_team_mode = multi_is_team_game_type(); - rf::Player* spectate_target = multi_spectate_is_first_person() ? multi_spectate_get_target_player() : nullptr; + rf::Player* spectate_target = multi_spectate_is_following_player() ? multi_spectate_get_target_player() : nullptr; bool is_local = (killed_player == rf::local_player || killer_player == rf::local_player || killed_player == spectate_target || killer_player == spectate_target); diff --git a/game_patch/multi/saved_info.cpp b/game_patch/multi/saved_info.cpp new file mode 100644 index 00000000..bf50bdd2 --- /dev/null +++ b/game_patch/multi/saved_info.cpp @@ -0,0 +1,113 @@ +#include "saved_info.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include "../rf/level.h" +#include "../rf/math/vector.h" +#include "../rf/file/file.h" +#include + +namespace +{ + // Build "\saved\_saved.afl". Returns empty if no level is loaded or the path is too + // long. When create_dir is set, the \saved\ directory is created if missing (used for writing). + std::string saved_info_path(bool create_dir) + { + std::string map = std::string{get_filename_without_ext(rf::level.filename.c_str())}; + if (map.empty()) + return {}; + auto dir = std::format("{}\\saved", rf::root_path); + if (dir.size() + map.size() + 16 > rf::max_path_len) { + xlog::error("saved-info path is too long!"); + return {}; + } + if (create_dir && !CreateDirectoryA(dir.c_str(), nullptr) && GetLastError() != ERROR_ALREADY_EXISTS) { + xlog::error("Failed to create saved directory, error {}", GetLastError()); + return {}; + } + return std::format("{}\\{}_saved.afl", dir, map); + } +} + +std::optional saved_info_read() +{ + auto path = saved_info_path(false); + if (path.empty()) + return std::nullopt; + + toml::table root; + try { + root = toml::parse_file(path); + } + catch (const toml::parse_error&) { + return std::nullopt; // missing or malformed - start clean + } + + if (const auto* header = root.get_as("header")) { + const auto ver = (*header)["afl_version"].value().value_or(0); + if (ver > AFL_VERSION) { + xlog::warn("Ignoring saved-info {} written by a newer Alpine Faction (afl_version {})", path, ver); + return std::nullopt; + } + } + + return root; +} + +bool saved_info_write(const toml::table& sections) +{ + auto path = saved_info_path(true); + if (path.empty()) + return false; + + toml::table root{ + {"header", toml::table{ + {"afl_version", AFL_VERSION}, + {"level", std::string{rf::level.filename.c_str()}}, + }}, + }; + for (auto&& [key, value] : sections) { + if (key.str() == "header") + continue; // reserved: saved_info owns the version/level header + root.insert_or_assign(key, value); + } + + std::ofstream file(path); + if (!file.is_open()) { + xlog::error("Failed to open saved-info file for write {}", path); + return false; + } + file << root; + return true; +} + +bool saved_info_read_vec3(const toml::array* arr, rf::Vector3& out) +{ + if (!arr || arr->size() != 3) + return false; + // Accept both floating-point and integer TOML nodes (so a hand-edited `pos = [1,2,3]` isn't + // silently dropped) and reject any non-finite (nan/inf) component. + auto read_component = [](const toml::node* n, float& out_component) -> bool { + if (!n) + return false; + double v; + if (const auto d = n->value()) + v = *d; + else if (const auto i = n->value()) + v = static_cast(*i); + else + return false; + if (!std::isfinite(v)) + return false; + out_component = static_cast(v); + return true; + }; + return read_component(arr->get(0), out.x) + && read_component(arr->get(1), out.y) + && read_component(arr->get(2), out.z); +} diff --git a/game_patch/multi/saved_info.h b/game_patch/multi/saved_info.h new file mode 100644 index 00000000..7451efe4 --- /dev/null +++ b/game_patch/multi/saved_info.h @@ -0,0 +1,9 @@ +#pragma once + +#include +#include + +namespace rf { struct Vector3; } +std::optional saved_info_read(); +bool saved_info_write(const toml::table& sections); +bool saved_info_read_vec3(const toml::array* arr, rf::Vector3& out); diff --git a/game_patch/rf/player/camera.h b/game_patch/rf/player/camera.h index 97191346..91a5d78a 100644 --- a/game_patch/rf/player/camera.h +++ b/game_patch/rf/player/camera.h @@ -50,4 +50,18 @@ namespace rf static auto& camera_get_mode = addr_as_ref(0x0040D740); static auto& camera_shake = addr_as_ref(0x0040E0B0); + + static auto& fixed_camera_count = addr_as_ref(0x005AFB38); + static auto& fixed_camera_index = addr_as_ref(0x005AFB6C); + static auto& fixed_camera_look_target_handle = addr_as_ref(0x007C7190); // -1 if none + + // Parallel position/orientation arrays for fixed cameras. + inline Vector3* fixed_camera_get_pos(int index) + { + return &addr_as_ref(0x005AFB40)[index]; + } + inline Matrix3* fixed_camera_get_orient(int index) + { + return &addr_as_ref(0x005AFB68)[index]; + } } diff --git a/game_patch/rf/player/control_config.h b/game_patch/rf/player/control_config.h index d1d841b5..d8a50895 100644 --- a/game_patch/rf/player/control_config.h +++ b/game_patch/rf/player/control_config.h @@ -53,9 +53,10 @@ namespace rf AF_ACTION_NO_AUTOSWITCH = 0xC, AF_ACTION_REMOTE_SERVER_CFG = 0xD, AF_ACTION_INSPECT_WEAPON = 0xE, - AF_ACTION_SPECTATE_TOGGLE_FREELOOK = 0xF, + AF_ACTION_SPECTATE_ATTACH = 0xF, AF_ACTION_SPECTATE_TOGGLE = 0x10, - AF_ACTION_SPRAY = 0x11, + AF_ACTION_SPECTATE_CHANGE_VIEW = 0x11, + AF_ACTION_SPRAY = 0x12, _AF_ACTION_LAST_VARIANT = AF_ACTION_SPRAY }; diff --git a/game_patch/rf/player/player.h b/game_patch/rf/player/player.h index c2bb497e..b7ab5a2f 100644 --- a/game_patch/rf/player/player.h +++ b/game_patch/rf/player/player.h @@ -222,6 +222,7 @@ namespace rf enum PlayerFlags { + PF_HIDE_FROM_CAMERA = 0x10, PF_KILL_AFTER_BLACKOUT = 0x200, PF_END_LEVEL_AFTER_BLACKOUT = 0x1000, };