diff --git a/game_patch/hud/multi_spectate.cpp b/game_patch/hud/multi_spectate.cpp index 090527a2..e998b004 100644 --- a/game_patch/hud/multi_spectate.cpp +++ b/game_patch/hud/multi_spectate.cpp @@ -13,6 +13,7 @@ #include "../rf/gr/gr_font.h" #include "../rf/hud.h" #include "../rf/player/camera.h" +#include "../rf/player/player_fpgun.h" #include "../main/main.h" #include "../misc/player.h" #include "../misc/alpine_settings.h" @@ -30,6 +31,12 @@ static rf::Camera* g_old_target_camera = nullptr; static bool g_spectate_mode_enabled = false; static bool g_spectate_mode_follow_killer = false; +// Edge-detection state for spectated player action animations +static bool g_prev_weapon_is_on = false; +static bool g_prev_is_reloading = false; +static bool g_prev_alt_fire_is_on = false; +static int g_prev_weapon_type = -1; + void player_fpgun_set_player(rf::Player* pp); static void set_camera_target(rf::Player* player) @@ -155,6 +162,11 @@ void multi_spectate_set_target_player(rf::Player* player) if (g_spectate_mode_target != player) { af_send_spectate_start_packet(player); g_spectate_mode_target = player; + // Reset action animation edge-detection state for new target + g_prev_weapon_is_on = false; + g_prev_is_reloading = false; + g_prev_alt_fire_is_on = false; + g_prev_weapon_type = -1; } rf::multi_kill_local_player(); @@ -415,6 +427,25 @@ static ConsoleCommand2 spectate_mode_follow_killer_cmd{ #if SPECTATE_MODE_SHOW_WEAPON +// Hook entity_play_attack_anim (0x0042C3C0) — called from the obj_update processing path +// when a remote entity's attack animation bits change. This fires at the correct time for +// thrown projectile weapons (grenade, C4, flamethrower canister), before the projectile +// itself arrives. For non-thrown weapons the existing multi_process_remote_weapon_fire_hook +// path also calls multi_spectate_on_obj_update_fire, but the !is_playing guard prevents +// double-triggering. +FunHook entity_play_attack_anim_spectate_hook{ + 0x0042C3C0, + [](rf::Entity* entity, bool alt_fire) { + entity_play_attack_anim_spectate_hook.call_target(entity, alt_fire); + if (!g_spectate_mode_enabled || !g_spectate_mode_target || !entity || rf::is_server) + return; + rf::Entity* target = rf::entity_from_handle(g_spectate_mode_target->entity_handle); + if (entity != target || g_spectate_mode_target == rf::local_player) + return; + multi_spectate_on_obj_update_fire(entity, alt_fire); + }, +}; + static void player_render_new(rf::Player* player) { if (g_spectate_mode_enabled) { @@ -425,12 +456,69 @@ static void player_render_new(rf::Player* player) (entity && entity->ai.current_primary_weapon == rf::remote_charge_weapon_type); if (g_spectate_mode_target != rf::local_player && entity) { - static rf::Vector3 old_vel; - rf::Vector3 vel_diff = entity->p_data.vel - old_vel; - old_vel = entity->p_data.vel; - - if (vel_diff.y > 0.1f) - entity->entity_flags |= rf::EF_JUMP_START_ANIM; // jump + // Clear jump/land flags so player_fpgun_process doesn't play WA_JUMP + // which cancels action anims (reload, fire, draw, etc.) + entity->entity_flags &= ~rf::EF_JUMP_START_ANIM; + g_spectate_mode_target->just_landed = false; + + int weapon_type = entity->ai.current_primary_weapon; + + // Detect weapon switch and play draw animation for the new weapon + if (weapon_type != g_prev_weapon_type && g_prev_weapon_type != -1) { + if (rf::player_fpgun_action_anim_exists(weapon_type, rf::WA_DRAW)) { + rf::player_fpgun_play_anim(g_spectate_mode_target, rf::WA_DRAW); + } + } + g_prev_weapon_type = weapon_type; + + // Detect weapon fire rising/falling edge. + // AIF_ALT_FIRE distinguishes primary vs alt fire on the same weapon_is_on state. + // For continuous alt fire weapons (baton taser): skip WA_CUSTOM_START intro, go + // straight to WS_LOOP_FIRE on rising edge, play WA_CUSTOM_LEAVE on falling edge. + bool weapon_is_on = rf::entity_weapon_is_on(entity->handle, weapon_type); + bool is_alt_fire = (entity->ai.ai_flags & rf::AIF_ALT_FIRE) != 0; + bool is_continuous_alt_fire_weapon = + rf::weapon_is_on_off_weapon(weapon_type, true); + + if (weapon_is_on && !g_prev_weapon_is_on) { + // Rising edge - weapon just started firing + if (is_alt_fire && is_continuous_alt_fire_weapon) { + // Continuous alt fire (baton taser): skip intro, go straight to looping fire + rf::player_fpgun_set_next_state_anim(g_spectate_mode_target, rf::WS_LOOP_FIRE); + } + else if (is_alt_fire && + rf::player_fpgun_action_anim_exists(weapon_type, rf::WA_ALT_FIRE)) { + rf::player_fpgun_play_anim(g_spectate_mode_target, rf::WA_ALT_FIRE); + } + else if (!is_alt_fire && + rf::player_fpgun_action_anim_exists(weapon_type, rf::WA_FIRE)) { + rf::player_fpgun_play_anim(g_spectate_mode_target, rf::WA_FIRE); + } + // Reset firing timer so muzzle flash renders (used by rail gun glow, + // shoulder cannon boom, and other time-based effects in player_fpgun_render) + g_spectate_mode_target->fpgun_data.time_elapsed_since_firing = 0.0f; + } + else if (!weapon_is_on && g_prev_weapon_is_on) { + // Falling edge - weapon stopped firing + if (g_prev_alt_fire_is_on && is_continuous_alt_fire_weapon && + rf::player_fpgun_action_anim_exists(weapon_type, rf::WA_CUSTOM_LEAVE)) { + rf::player_fpgun_play_anim(g_spectate_mode_target, rf::WA_CUSTOM_LEAVE); + } + } + g_prev_weapon_is_on = weapon_is_on; + g_prev_alt_fire_is_on = is_alt_fire; + + // Detect reload rising edge and play fpgun reload action animation + // Skip if the player has no reserve ammo (empty weapon causes continuous reload flag) + bool is_reloading = rf::entity_is_reloading(entity); + if (is_reloading && !g_prev_is_reloading) { + int ammo_type = rf::weapon_types[weapon_type].ammo_type; + bool has_reserve_ammo = ammo_type >= 0 && entity->ai.ammo[ammo_type] > 0; + if (has_reserve_ammo && rf::player_fpgun_action_anim_exists(weapon_type, rf::WA_RELOAD)) { + rf::player_fpgun_play_anim(g_spectate_mode_target, rf::WA_RELOAD); + } + } + g_prev_is_reloading = is_reloading; } if (g_spectate_mode_target->fpgun_data.zooming_in) @@ -439,6 +527,15 @@ static void player_render_new(rf::Player* player) rf::local_player->fpgun_data.zoom_factor = g_spectate_mode_target->fpgun_data.zoom_factor; rf::player_fpgun_process(g_spectate_mode_target); + + // Force WS_LOOP_FIRE state after process so the render function sees it for muzzle flash. + // The state anim hook inside process should already set this, but the animation transition + // system may not complete in time for the render check. Directly writing the state fields + // guarantees player_fpgun_render's is_in_state_anim(WS_LOOP_FIRE) check passes. + if (entity && rf::entity_weapon_is_on(entity->handle, entity->ai.current_primary_weapon)) { + g_spectate_mode_target->fpgun_current_state_anim = rf::WS_LOOP_FIRE; + } + rf::player_render(g_spectate_mode_target); } else @@ -457,6 +554,40 @@ CallHook gameplay_render_frame_player_fpgun_get_zoom_hook{ #endif // SPECTATE_MODE_SHOW_WEAPON +void multi_spectate_on_obj_update_fire(rf::Entity* entity, bool alt_fire) +{ +#if SPECTATE_MODE_SHOW_WEAPON + if (!g_spectate_mode_enabled || !g_spectate_mode_target || !entity) + return; + + rf::Entity* target_entity = rf::entity_from_handle(g_spectate_mode_target->entity_handle); + if (entity != target_entity) + return; + + if (g_spectate_mode_target == rf::local_player) + return; + + int weapon_type = entity->ai.current_primary_weapon; + + // Continuous alt fire weapons (baton taser): skip intro, go straight to looping fire + if (alt_fire && rf::weapon_is_on_off_weapon(weapon_type, true)) { + rf::player_fpgun_set_next_state_anim(g_spectate_mode_target, rf::WS_LOOP_FIRE); + } + else { + rf::WeaponAction action = alt_fire ? rf::WA_ALT_FIRE : rf::WA_FIRE; + if (rf::player_fpgun_action_anim_exists(weapon_type, action)) { + bool should_play = rf::weapon_is_semi_automatic(weapon_type) + || !rf::player_fpgun_action_anim_is_playing(g_spectate_mode_target, action); + if (should_play) { + rf::player_fpgun_play_anim(g_spectate_mode_target, action); + } + } + } + + g_spectate_mode_target->fpgun_data.time_elapsed_since_firing = 0.0f; +#endif +} + void multi_spectate_appy_patch() { render_reticle_hook.install(); @@ -472,6 +603,7 @@ void multi_spectate_appy_patch() AsmWriter(0x0043285D).call(player_render_new); gameplay_render_frame_player_fpgun_get_zoom_hook.install(); + entity_play_attack_anim_spectate_hook.install(); write_mem_ptr(0x0048857E + 2, &g_spectate_mode_target); // obj_mark_all_for_room write_mem_ptr(0x00488598 + 1, &g_spectate_mode_target); // obj_mark_all_for_room diff --git a/game_patch/hud/multi_spectate.h b/game_patch/hud/multi_spectate.h index e9fa108b..07a1f3cc 100644 --- a/game_patch/hud/multi_spectate.h +++ b/game_patch/hud/multi_spectate.h @@ -20,3 +20,4 @@ void multi_spectate_player_create_entity_post(rf::Player* player, rf::Entity* en bool multi_spectate_is_spectating(); bool multi_spectate_execute_action(rf::ControlConfigAction action, bool was_pressed); void multi_spectate_sync_crouch_anim(); +void multi_spectate_on_obj_update_fire(rf::Entity* entity, bool alt_fire); diff --git a/game_patch/misc/player_fpgun.cpp b/game_patch/misc/player_fpgun.cpp index 7ec6e2b1..678981a3 100644 --- a/game_patch/misc/player_fpgun.cpp +++ b/game_patch/misc/player_fpgun.cpp @@ -23,23 +23,29 @@ static rf::Player* g_fpgun_main_player = nullptr; static FunHook player_fpgun_update_state_anim_hook{ 0x004AA3A0, [](rf::Player* player) { - player_fpgun_update_state_anim_hook.call_target(player); - if (player != rf::local_player) { - rf::Entity* entity = rf::entity_from_handle(player->entity_handle); - if (entity) { - float horz_speed_pow2 = entity->p_data.vel.x * entity->p_data.vel.x + - entity->p_data.vel.z * entity->p_data.vel.z; - int state = rf::WS_IDLE; - if (rf::entity_weapon_is_on(entity->handle, entity->ai.current_primary_weapon)) - state = rf::WS_LOOP_FIRE; - else if (rf::entity_is_swimming(entity) || rf::entity_is_falling(entity)) - state = rf::WS_IDLE; - else if (horz_speed_pow2 > 0.2f) - state = rf::WS_RUN; - if (!rf::player_fpgun_is_in_state_anim(player, state)) - rf::player_fpgun_set_next_state_anim(player, state); - } + if (player == rf::local_player) { + // Only run the original state anim logic for the local player + player_fpgun_update_state_anim_hook.call_target(player); + return; } + rf::Entity* entity = rf::entity_from_handle(player->entity_handle); + if (!entity) + return; + // When falling, keep the current fpgun state anim to avoid + // cancelling action anims (reload, fire, draw, etc.) during spectate + if (rf::entity_is_falling(entity)) + return; + int state = rf::WS_IDLE; + if (rf::entity_weapon_is_on(entity->handle, entity->ai.current_primary_weapon)) + state = rf::WS_LOOP_FIRE; + else if (!rf::entity_is_swimming(entity)) { + float horz_speed_pow2 = entity->p_data.vel.x * entity->p_data.vel.x + + entity->p_data.vel.z * entity->p_data.vel.z; + if (horz_speed_pow2 > 0.2f) + state = rf::WS_RUN; + } + if (!rf::player_fpgun_is_in_state_anim(player, state)) + rf::player_fpgun_set_next_state_anim(player, state); }, }; @@ -310,6 +316,12 @@ void player_fpgun_do_patch() AsmWriter(0x004AA6E7).nop(6); // player_fpgun_process AsmWriter(0x004AE384).nop(6); // player_fpgun_page_in write_mem(0x004ACE2C, asm_opcodes::jmp_rel_short); // player_fpgun_get_zoom + write_mem(0x004AD6E0, asm_opcodes::jmp_rel_short); // player_fpgun_get_muzzle_tag_pos + AsmWriter(0x004ACC8E).nop(6); // player_fpgun_get_non_bullet_muzzle_flash_info + AsmWriter(0x004AB03D).nop(2); // player_fpgun_is_firing_or_reloading: run checks for all players + write_mem(0x004AB0CC, asm_opcodes::jmp_rel_short); // player_fpgun_is_holstering_or_drawing: run checks for all players + write_mem(0x004ADB6C, asm_opcodes::jmp_rel_short); // player_fpgun_is_in_custom_anim: run checks for all players + AsmWriter(0x004AD8CE).nop(6); // player_fpgun_action_anim_is_playing: run checks for all players write_mem_ptr(0x004AE569 + 2, &g_fpgun_main_player); // player_fpgun_load_meshes write_mem_ptr(0x004AE5E3 + 2, &g_fpgun_main_player); // player_fpgun_load_meshes diff --git a/game_patch/multi/alpine_packets.cpp b/game_patch/multi/alpine_packets.cpp index 7112fc42..ab9bf895 100644 --- a/game_patch/multi/alpine_packets.cpp +++ b/game_patch/multi/alpine_packets.cpp @@ -1787,3 +1787,4 @@ void af_process_server_msg_packet( rf::console::print("{}", msg); } } + diff --git a/game_patch/multi/multi.cpp b/game_patch/multi/multi.cpp index a578550b..293b3eb5 100644 --- a/game_patch/multi/multi.cpp +++ b/game_patch/multi/multi.cpp @@ -15,6 +15,7 @@ #include "server_internal.h" #include "gametype.h" #include "../hud/hud.h" +#include "../hud/multi_spectate.h" #include "../rf/file/file.h" #include "../rf/level.h" #include "../os/console.h" @@ -521,6 +522,18 @@ FunHook multi_process_ } } multi_process_remote_weapon_fire_hook.call_target(ep, weapon_type, pos, orient, alt_fire); + + // Notify spectate system of weapon fire so the fpgun fire animation is triggered. + // Skip thrown projectile weapons (grenade, C4, flamethrower canister alt-fire) because + // their animation is driven earlier and at the correct time by entity_play_attack_anim_spectate_hook. + if (!rf::is_server) { + bool is_thrown = (weapon_type == rf::grenade_weapon_type) + || (weapon_type == rf::remote_charge_weapon_type) + || (rf::weapon_is_flamethrower(weapon_type) && alt_fire); + if (!is_thrown) { + multi_spectate_on_obj_update_fire(ep, alt_fire); + } + } }, }; diff --git a/game_patch/rf/player/player_fpgun.h b/game_patch/rf/player/player_fpgun.h index 44c7d8b5..a794beb2 100644 --- a/game_patch/rf/player/player_fpgun.h +++ b/game_patch/rf/player/player_fpgun.h @@ -80,6 +80,7 @@ namespace rf static auto& player_fpgun_render_ir = addr_as_ref(0x004AEEF0); static auto& player_fpgun_set_next_state_anim = addr_as_ref(0x004AA560); static auto& player_fpgun_is_in_state_anim = addr_as_ref(0x004A9520); + static auto& player_fpgun_is_in_custom_anim = addr_as_ref(0x004ADB60); static auto& player_fpgun_clear_all_action_anim_sounds = addr_as_ref(0x004A9490); static auto& player_fpgun_load_meshes = addr_as_ref(0x004AE530); static auto& player_fpgun_delete_meshes = addr_as_ref(0x004AEB40);