From b5f8fb87f2b81715c407e49ae8cd73ed27758201 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 22 Feb 2026 12:18:27 -0500 Subject: [PATCH] add freelook toggle bind in spectator mode --- docs/CHANGELOG.md | 1 + game_patch/hud/multi_spectate.cpp | 65 ++++++++++++++++++++++++++- game_patch/hud/multi_spectate.h | 2 + game_patch/input/key.cpp | 7 +++ game_patch/rf/player/control_config.h | 3 +- 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 94f7fb9a..a6a08bea 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,6 +51,7 @@ Version 1.3.0 (Bakeapple): Not yet released [@nickalreadyinuse](https://github.com/nickalreadyinuse) - Optimize network performance for `af_obj_update` packets and bot decommission logic - Sync animation state for crouched players in first person spectate view +- Add freelook/first person toggle bind in spectator mode Version 1.2.2 (Willow): Released Jan-04-2026 -------------------------------- diff --git a/game_patch/hud/multi_spectate.cpp b/game_patch/hud/multi_spectate.cpp index 090527a2..ee62f1ed 100644 --- a/game_patch/hud/multi_spectate.cpp +++ b/game_patch/hud/multi_spectate.cpp @@ -29,6 +29,7 @@ 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_follow_killer = false; +static rf::Player* g_spectate_freelook_saved_target = nullptr; void player_fpgun_set_player(rf::Player* pp); @@ -232,6 +233,61 @@ bool multi_spectate_is_spectating() return g_spectate_mode_enabled || multi_spectate_is_freelook(); } +bool multi_spectate_is_first_person() +{ + return g_spectate_mode_enabled; +} + +void multi_spectate_toggle_freelook() +{ + if (!multi_spectate_is_spectating()) + return; + + 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; + +#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; +#endif + } + + g_spectate_mode_enabled = false; + g_spectate_mode_target = rf::local_player; + +#if SPECTATE_MODE_SHOW_WEAPON + player_fpgun_set_player(rf::local_player); +#endif + + // Now enter freelook cleanly + multi_spectate_enter_freelook(); + } + 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); + } + else { + // Saved target is gone, find any player + g_spectate_mode_target = rf::local_player; + spectate_next_player(true, true); + } + g_spectate_freelook_saved_target = nullptr; + } +} + rf::Player* multi_spectate_get_target_player() { return g_spectate_mode_target; @@ -273,7 +329,8 @@ bool multi_spectate_execute_action(rf::ControlConfigAction action, bool was_pres else if (multi_spectate_is_freelook()) { // don't allow respawn in freelook spectate if (action == rf::CC_ACTION_PRIMARY_ATTACK || action == rf::CC_ACTION_SECONDARY_ATTACK) { - if (was_pressed) + // If we entered freelook via toggle, just consume the click without leaving + if (!g_spectate_freelook_saved_target && was_pressed) multi_spectate_leave(); return true; } @@ -304,6 +361,8 @@ 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) { + 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) @@ -588,9 +647,13 @@ void multi_spectate_render() { std::string spec_menu_text = get_action_bind_name( get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_MENU) ); + std::string toggle_freelook_text = get_action_bind_name( + get_af_control(rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK) + ); const char* hints[][3] = { {next_player_text.c_str(), "Next Player"}, {prev_player_text.c_str(), "Previous Player"}, + {toggle_freelook_text.c_str(), "Toggle Freelook"}, {spec_menu_text.c_str(), "Open Spectate Options Menu"}, {exit_spec_text.c_str(), "Exit Spectate Mode"}, }; diff --git a/game_patch/hud/multi_spectate.h b/game_patch/hud/multi_spectate.h index e9fa108b..d8f64a36 100644 --- a/game_patch/hud/multi_spectate.h +++ b/game_patch/hud/multi_spectate.h @@ -18,5 +18,7 @@ void multi_spectate_on_player_kill(rf::Player* player, rf::Player* killer); 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(); +void multi_spectate_toggle_freelook(); bool multi_spectate_execute_action(rf::ControlConfigAction action, bool was_pressed); void multi_spectate_sync_crouch_anim(); diff --git a/game_patch/input/key.cpp b/game_patch/input/key.cpp index 0ef4db6b..edf260ae 100644 --- a/game_patch/input/key.cpp +++ b/game_patch/input/key.cpp @@ -267,6 +267,8 @@ 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, "Toggle Freelook Spectate", false, rf::KEY_PERIOD, -1, -1, + rf::AlpineControlConfigAction::AF_ACTION_SPECTATE_TOGGLE_FREELOOK); }, }; @@ -393,6 +395,11 @@ CodeInjection player_execute_action_patch3{ == static_cast(rf::AlpineControlConfigAction::AF_ACTION_REMOTE_SERVER_CFG) && 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) + && !rf::is_dedicated_server + && multi_spectate_is_spectating()) { + multi_spectate_toggle_freelook(); } } }, diff --git a/game_patch/rf/player/control_config.h b/game_patch/rf/player/control_config.h index f6a5e5d8..690424d9 100644 --- a/game_patch/rf/player/control_config.h +++ b/game_patch/rf/player/control_config.h @@ -53,7 +53,8 @@ namespace rf AF_ACTION_NO_AUTOSWITCH = 0xC, AF_ACTION_REMOTE_SERVER_CFG = 0xD, AF_ACTION_INSPECT_WEAPON = 0xE, - _AF_ACTION_LAST_VARIANT = AF_ACTION_INSPECT_WEAPON + AF_ACTION_SPECTATE_TOGGLE_FREELOOK = 0xF, + _AF_ACTION_LAST_VARIANT = AF_ACTION_SPECTATE_TOGGLE_FREELOOK }; struct ControlConfigItem