From 2fcb56584bc49c531b8d8a54ccd598bc9da9b6b7 Mon Sep 17 00:00:00 2001 From: SupImCinnamon Date: Mon, 24 Aug 2026 10:36:09 +0200 Subject: [PATCH 1/4] Base 1P --- .../TmCameraModule_SpectatorFirstPerson.uc | 100 ++++++++++ .../Src/TmCore/Classes/TmDemoRecSpectator.uc | 175 ++++++++++++++++++ .../TmCore/Classes/TmGameViewportClient.uc | 1 + .../TmCore/Classes/TmSpectatorController.uc | 127 +++++++++++++ 4 files changed, 403 insertions(+) create mode 100644 Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc create mode 100644 Development/Src/TmCore/Classes/TmDemoRecSpectator.uc diff --git a/Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc b/Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc new file mode 100644 index 00000000..39e538ae --- /dev/null +++ b/Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc @@ -0,0 +1,100 @@ +class TmCameraModule_SpectatorFirstPerson extends TgCameraModule + config(Game); + +// True while the native gate reports a third person tick (ability, mount...). +// We keep this module active and pose the camera behind-view ourselves: ever +// switching to a TgCameraModule_ThirdPerson-derived module would make +// ATgPlayerController::Wants3P() return true and lock the native first person +// gate closed after the ability ends. +var bool m_bForced3P; + +function UpdateCamera(Pawn P, TgPlayerCamera CameraActor, float DeltaTime, out TViewTarget OutVT) +{ + local Vector camOrigin, boneLoc, Offset; + local Rotator CamRot; + local TgPawn TgP; + local TgWeaponMeshActor WMA; + + if (CameraActor == none || CameraActor.PlayerOwner == none) + return; + + TgP = TgPawn(OutVT.Target); + if (TgP != none) + WMA = TgP.m_WeaponMesh; + + CamRot = CameraActor.PlayerOwner.Rotation; + if (TgP != none) + { + CamRot = TgP.Rotation; + CamRot.Pitch = TgP.m_nSmoothedRemoteViewPitch; + } + + OutVT.POV.Rotation = CamRot; + + if (m_bForced3P && TgP != none) + { + // Behind-view pose mirroring the stock spectator third person module. + camOrigin = GetBodyAttachPoint(TgP); + Offset.X = -142.0; + Offset.Z = 25.0; + OutVT.POV.Location = camOrigin + (Offset >> CamRot) + TgP.WalkBob; + return; + } + + // Prefer the rig's camera bone (same source as the native first person path), + // but only trust it when the rig was actually built near the pawn this frame. + if (TgP != none && WMA != none && WMA.m_bUseSocketForCameraAttach && WMA.CanUseCameraBone() && WMA.m_WeaponMesh1P != none) + { + boneLoc = WMA.m_WeaponMesh1P.GetBoneLocation('Camera_bn'); + if (VSizeSq(boneLoc - TgP.Location) < 90000.0) + { + OutVT.POV.Location = boneLoc; + OutVT.POV.Rotation = OrthoRotation( + WMA.m_WeaponMesh1P.GetBoneAxis('Camera_bn', 4) * -1.0, + WMA.m_WeaponMesh1P.GetBoneAxis('Camera_bn', 1), + WMA.m_WeaponMesh1P.GetBoneAxis('Camera_bn', 2) * -1.0); + return; + } + } + + if (TgP != none) + { + camOrigin = GetBodyAttachPoint(TgP); + OutVT.POV.Location = camOrigin + TgP.WalkBob; + } + else if (OutVT.Target != none) + { + OutVT.POV.Location = OutVT.Target.Location; + } +} + +// Same anchor math as TgCameraModule_ThirdPerson.GetCamAttachPoint's fallback. +simulated function Vector GetBodyAttachPoint(TgPawn TgP) +{ + local Vector NewCameraLoc; + + NewCameraLoc = TgP.Location; + if (TgP.Mesh != none) + { + if (TgP.Mesh.MatchRefBone('EffectsSocket') != -1) + { + NewCameraLoc = TgP.Mesh.GetBoneLocation('EffectsSocket'); + NewCameraLoc.Z += TgP.GetCollisionHeight(); + } + else if (TgP.Mesh.MatchRefBone('Root') != -1) + { + NewCameraLoc = TgP.Mesh.GetBoneLocation('Root'); + NewCameraLoc.Z += TgP.GetCollisionHeight(); + } + else + { + NewCameraLoc = TgP.Mesh.GetBoneLocation(TgP.Mesh.GetBoneName(0)); + NewCameraLoc.Z += TgP.GetCollisionHeight(); + } + } + if (int(TgP.c_LocalPolymorph) == 0) + NewCameraLoc.Z += TgP.EyeHeight; + else + NewCameraLoc.Z += 20.0; + return NewCameraLoc; +} diff --git a/Development/Src/TmCore/Classes/TmDemoRecSpectator.uc b/Development/Src/TmCore/Classes/TmDemoRecSpectator.uc new file mode 100644 index 00000000..041c7e3a --- /dev/null +++ b/Development/Src/TmCore/Classes/TmDemoRecSpectator.uc @@ -0,0 +1,175 @@ +class TmDemoRecSpectator extends TgDemoRecSpectator + config(Game) + hidecategories(Navigation) + dependson(TgObject); + +// Mirrored into the demo file so playback can restore the exact POV. +// ViewTarget is not a replicated property in UE3 and m_CameraMode is +// transient, so without this the replayed demo spectator sits at world +// origin in free-fly mode. This is also why the first person rig never +// builds on playback: TgPawn.IsFirstPerson() requires the local spectator +// controller to have GetViewTarget() == pawn && m_CameraMode == SpecCam_FollowFirstPerson. +var repnotify Actor m_rDemoViewTarget; +var repnotify byte m_rDemoCameraMode; + +var transient bool m_bPendingViewTarget; +var transient bool m_bPendingCameraMode; + +simulated event PostBeginPlay() +{ + // Intentionally skipping TgDemoRecSpectator.PostBeginPlay(): + // it registers engine callbacks, starts the MCTS spectator master/slave + // sync and the auto combat log. None of that can work on a bare dedicated + // server and the master/slave sync stalls normal client replication while + // a recording is active. Everything else in the chain still runs. + super(TgSpectatorController).PostBeginPlay(); + + if (Role == ROLE_Authority && WorldInfo.NetMode != NM_Client) + { + SetTimer(0.5, true, 'TmRecordTick'); + } + else if (WorldInfo.IsRecordingDemo()) + { + // Client-side recording: mirror what the human spectator is watching. + SetTimer(0.25, true, 'TmMirrorLocalSpectator'); + } + else if (WorldInfo.IsPlayingDemo()) + { + SetTimer(0.25, true, 'TmApplyRecordedState'); + } +} + +// Client-side recording variant: copy the local player's spectator state +// into the replicated mirror so playback restores the same POV. +function TmMirrorLocalSpectator() +{ + local TgSpectatorController Spec; + local Actor VT; + + if (!WorldInfo.IsRecordingDemo()) + return; + + Spec = TgSpectatorController(GetALocalPlayerController()); + if (Spec == none || Spec == self) + return; + + VT = Spec.GetViewTarget(); + if (VT != none && VT != m_rDemoViewTarget) + m_rDemoViewTarget = VT; + + if (Spec.m_CameraMode != m_rDemoCameraMode) + m_rDemoCameraMode = Spec.m_CameraMode; +} + +// Recording side housekeeping: flag PRIs so tracked projectile data gets +// replicated into the stream, keep an alive viewtarget, and mirror the +// current POV state into the replicated properties. +function TmRecordTick() +{ + local TgRepInfo_Player PRI; + local TgPawn_Character P; + local Actor VT; + local bool bFound; + + if (!WorldInfo.IsRecordingDemo()) + return; + + foreach WorldInfo.AllActors(Class'TgGame.TgRepInfo_Player', PRI) + { + if (!PRI.bDemoOwner) + PRI.bDemoOwner = true; + } + + VT = GetViewTarget(); + if (VT == none || TgPawn(VT) == none || !TgPawn(VT).IsAliveAndWell() || VT.IsInState('Dying')) + { + foreach WorldInfo.AllPawns(Class'TgGame.TgPawn_Character', P) + { + if (P.IsAliveAndWell() && !P.IsInState('Dying')) + { + SpectatorSetViewTarget(P); + SetSpectatorCameraMode(SpectatorCameraMode.SpecCam_FollowThirdPerson); + bFound = true; + break; + } + } + if (!bFound) + return; + } + + VT = GetViewTarget(); + if (VT != none && VT != m_rDemoViewTarget) + m_rDemoViewTarget = VT; + + if (m_CameraMode != m_rDemoCameraMode) + m_rDemoCameraMode = m_CameraMode; +} + +simulated event ReplicatedEvent(name VarName) +{ + if (VarName == 'm_rDemoViewTarget') + { + m_bPendingViewTarget = m_rDemoViewTarget != none; + return; + } + + if (VarName == 'm_rDemoCameraMode') + { + m_bPendingCameraMode = true; + return; + } + + super.ReplicatedEvent(VarName); +} + +// Playback side: restore what was being watched. Uses the native +// SetViewTarget/SetSpectatorCameraMode directly instead of +// SpectatorSetViewTarget to avoid firing server RPCs into the void. +simulated function TmApplyRecordedState() +{ + if (!WorldInfo.IsPlayingDemo()) + { + ClearTimer('TmApplyRecordedState'); + return; + } + + if (m_bPendingViewTarget && m_rDemoViewTarget != none && GetViewTarget() != m_rDemoViewTarget) + { + SetViewTarget(m_rDemoViewTarget); + m_bPendingViewTarget = false; + } + + if (m_bPendingCameraMode && PlayerCamera != none && int(m_CameraMode) != int(m_rDemoCameraMode)) + { + //SetSpectatorCameraMode(m_rDemoCameraMode, false); + m_bPendingCameraMode = false; + } +} + +// While recording, make the server-side viewpoint track the followed pawn. +// The stock implementation spawns PlayerCamera whose cache is never updated +// outside of rendering, so every viewer query (FNetViewer, relevancy traces, +// viewer-dependent replication conditions) reads world origin. +simulated event GetPlayerViewPoint(out Vector out_Location, out Rotator out_Rotation) +{ + local Actor VT; + + if (Role == ROLE_Authority && WorldInfo.NetMode != NM_Client) + { + VT = GetViewTarget(); + if (VT != none) + { + out_Location = VT.Location; + out_Rotation = VT.Rotation; + return; + } + } + super.GetPlayerViewPoint(out_Location, out_Rotation); +} + +defaultproperties +{ + RemoteRole=ROLE_AutonomousProxy + bDemoOwner=true + bAlwaysTick=true +} diff --git a/Development/Src/TmCore/Classes/TmGameViewportClient.uc b/Development/Src/TmCore/Classes/TmGameViewportClient.uc index 05f2924f..8d9ce686 100644 --- a/Development/Src/TmCore/Classes/TmGameViewportClient.uc +++ b/Development/Src/TmCore/Classes/TmGameViewportClient.uc @@ -39,6 +39,7 @@ function PostRender(Canvas Canvas) if (TmSpectatorController(PC) != none) { + TmSpectatorController(PC).UpdateFirstPersonNudge(); TmSpectatorController(PC).TickSpectatorPlayerHUD(); TmSpectatorController(PC).TickSpectatorTeamHUD(); } diff --git a/Development/Src/TmCore/Classes/TmSpectatorController.uc b/Development/Src/TmCore/Classes/TmSpectatorController.uc index ea559697..8da1592d 100644 --- a/Development/Src/TmCore/Classes/TmSpectatorController.uc +++ b/Development/Src/TmCore/Classes/TmSpectatorController.uc @@ -17,6 +17,9 @@ var transient bool bClonedHUD; var transient int LastClonedTickCount; var transient int bBorderBuilt; +// Pawn currently forced into the client-side first person rig, if any. +var transient TgPawn m_NudgedFpPawn; + const ABILITY_SLOTS = 5; simulated function ForwardToSpectatingMatch() @@ -38,8 +41,132 @@ public function Class GetHudClass(Class pNewHudType) return Class'TgClient.TgGameHUD'; } +exec function SpecToggleFirstPerson() +{ + if (int(m_CameraMode) == int(SpectatorCameraMode.SpecCam_FollowFirstPerson)) + SetSpectatorCameraMode(SpectatorCameraMode.SpecCam_FollowThirdPerson); + else + SetSpectatorCameraMode(SpectatorCameraMode.SpecCam_FollowFirstPerson); +} + +// Same bookkeeping as the stock mode 3 handler, but swaps in our own camera +// module so the POV is driven from script instead of the native gate. +exec function SetSpectatorCameraMode(TgSpectatorController.SpectatorCameraMode Mode, optional bool bCameraTween = false) +{ + if (int(Mode) != int(SpectatorCameraMode.SpecCam_FollowFirstPerson)) + { + super.SetSpectatorCameraMode(Mode, bCameraTween); + ClearFirstPersonNudge(); + return; + } + + if (int(Mode) == int(m_CameraMode)) + return; + + m_CameraMode = Mode; + m_bIsMapSquashed = false; + if (bCameraTween) + TgPlayerCamera(PlayerCamera).SwitchCameras(class'TmCore.TmCameraModule_SpectatorFirstPerson', 0.2); + else + TgPlayerCamera(PlayerCamera).SwitchCameras(class'TmCore.TmCameraModule_SpectatorFirstPerson'); +} + +// Opens the native first person gate (ATgPawn::ShouldBeFirstPersonThisTick, +// live path) for the followed pawn. The gate requires: +// Controller != none, IsA(TgPlayerController) and !Controller->Wants3P() +// A spectated pawn normally fails the first test because other players' +// controllers are never replicated to us. Assigning ourselves client-side is +// safe: Pawn.Controller is owner-only replicated so it never leaves this +// machine, and the real owner keeps authoritative control server-side. +// Wants3P() returns true when the current camera module derives from +// TgCameraModule_ThirdPerson (with zoom applied) or when a 3P camera posture +// is pushed, which is why our FP camera module must NOT derive from any +// ThirdPerson module and why we keep the posture cleared. With the gate open, +// the engine builds the entire native first person rig (viewmodel, Camera_bn +// eye position) and its device loop adapts FP<->3P during abilities/mounts +// exactly like the followed player sees. The spectated pawn's world model is +// hidden absolutely (bOwnerNoSee cannot work: Owner never replicates to us). +simulated function UpdateFirstPersonNudge() +{ + local TgPawn ViewPawn; + local bool bNativeFP; + + if (m_CameraMode != SpectatorCameraMode.SpecCam_FollowFirstPerson) + { + ClearFirstPersonNudge(); + return; + } + + ViewPawn = TgPawn(GetViewTarget()); + // Never touch pawns we have authority over (listen server): the fake + // Controller assignment is only safe on simulated proxies. + if (ViewPawn == none || !ViewPawn.IsAliveAndWell() || ViewPawn.Role >= ROLE_Authority) + { + ClearFirstPersonNudge(); + return; + } + + if (m_NudgedFpPawn != none && m_NudgedFpPawn != ViewPawn) + ClearFirstPersonNudge(); + + m_NudgedFpPawn = ViewPawn; + ViewPawn.Controller = self; + + // Single source of truth: ask the engine whether this pawn is first person + // this tick. The native device loop already accounts for abilities, mounts + // and emotes; our camera module just flips between the eye pose and the + // behind-view pose without ever leaving its class. + bNativeFP = ViewPawn.ShouldBeFirstPersonThisTick(); + m_bBehindView = !bNativeFP; + + SetRealModelHidden(ViewPawn, bNativeFP); + SetForced3PPose(!bNativeFP); + + // Wants3P() folds in the posture stack; make sure nothing 3P is latched. + if (int(c_eCameraPosture) != 0) + c_eCameraPosture = TG_CAMERAPOSTURE_None; +} + +simulated function SetForced3PPose(bool bForced3P) +{ + local TmCameraModule_SpectatorFirstPerson CamMod; + + if (PlayerCamera == none) + return; + CamMod = TmCameraModule_SpectatorFirstPerson(TgPlayerCamera(PlayerCamera).CurrentCameraMod); + if (CamMod != none) + CamMod.m_bForced3P = bForced3P; +} + +// Hiding must be absolute: the pawn's Mesh uses bOwnerNoSee semantics against +// an Owner that is never replicated to spectators. +simulated function SetRealModelHidden(TgPawn ViewPawn, bool bHidden) +{ + if (ViewPawn.Mesh != none) + ViewPawn.Mesh.SetHidden(bHidden); + if (ViewPawn.m_WeaponMesh != none && ViewPawn.m_WeaponMesh.m_WeaponMesh3P != none) + ViewPawn.m_WeaponMesh.m_WeaponMesh3P.SetHidden(bHidden); +} + +simulated function ClearFirstPersonNudge() +{ + local TgPawn Nudged; + + if (m_NudgedFpPawn == none) + return; + + Nudged = m_NudgedFpPawn; + m_NudgedFpPawn = none; + SetRealModelHidden(Nudged, false); + SetForced3PPose(false); + if (Nudged.Controller == self) + Nudged.Controller = none; + m_bBehindView = true; +} + function SpectatorSetViewTarget(Actor VT, optional ViewTargetTransitionParams TransitionParams) { + ClearFirstPersonNudge(); super.SpectatorSetViewTarget(VT, TransitionParams); if (Role == ROLE_Authority && !bTimerOn) { From c542eea66f5a7d47e3fb4e251a2bb983c2aa266f Mon Sep 17 00:00:00 2001 From: SupImCinnamon Date: Mon, 24 Aug 2026 15:12:28 +0200 Subject: [PATCH 2/4] Fixed horse and model visibility --- Development/Src/TempestMp/Classes/Siege.uc | 2 + .../TmCameraModule_SpectatorFirstPerson.uc | 90 +++++-- .../TmCore/Classes/TmSpectatorController.uc | 227 +++++++++++++++--- 3 files changed, 268 insertions(+), 51 deletions(-) diff --git a/Development/Src/TempestMp/Classes/Siege.uc b/Development/Src/TempestMp/Classes/Siege.uc index e5603a94..057edc86 100644 --- a/Development/Src/TempestMp/Classes/Siege.uc +++ b/Development/Src/TempestMp/Classes/Siege.uc @@ -360,4 +360,6 @@ function SetupSpectator(TmProxyActor ProxyActor, TgPlayerController PC, Incoming SPC.SpectatorSetViewTarget(TargetPawn); ProxyActor.ClientConsoleCommand("setreadytoplay"); + ProxyActor.ClientConsoleCommand("spectogglefirstperson"); + } \ No newline at end of file diff --git a/Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc b/Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc index 39e538ae..a377fc54 100644 --- a/Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc +++ b/Development/Src/TmCore/Classes/TmCameraModule_SpectatorFirstPerson.uc @@ -2,16 +2,36 @@ class TmCameraModule_SpectatorFirstPerson extends TgCameraModule config(Game); // True while the native gate reports a third person tick (ability, mount...). -// We keep this module active and pose the camera behind-view ourselves: ever -// switching to a TgCameraModule_ThirdPerson-derived module would make -// ATgPlayerController::Wants3P() return true and lock the native first person -// gate closed after the ability ends. +// We keep this module active and pose the camera ourselves. var bool m_bForced3P; +// Pose transition state, mirroring TgCameraModule_SpectatorThirdPerson. +var transient TPOV OldViewpoint; +var transient float RemainingBlendTime; +var transient float TotalBlendTime; + +// Called by the spectator controller whenever the native gate flips between +// first and third person; kicks off a smooth pose blend instead of snapping. +function SetForced3P(bool bNewForced3P) +{ + if (m_bForced3P == bNewForced3P) + return; + + m_bForced3P = bNewForced3P; + if (PlayerCamera != none) + { + OldViewpoint = PlayerCamera.CameraCache.POV; + RemainingBlendTime = 0.25; + TotalBlendTime = 0.25; + } +} + function UpdateCamera(Pawn P, TgPlayerCamera CameraActor, float DeltaTime, out TViewTarget OutVT) { local Vector camOrigin, boneLoc, Offset; local Rotator CamRot; + local TPOV TargetPOV; + local float DurationPct, BlendPct; local TgPawn TgP; local TgWeaponMeshActor WMA; @@ -29,42 +49,68 @@ function UpdateCamera(Pawn P, TgPlayerCamera CameraActor, float DeltaTime, out T CamRot.Pitch = TgP.m_nSmoothedRemoteViewPitch; } - OutVT.POV.Rotation = CamRot; + TargetPOV.Rotation = CamRot; if (m_bForced3P && TgP != none) { - // Behind-view pose mirroring the stock spectator third person module. - camOrigin = GetBodyAttachPoint(TgP); + // Behind-view pose mirroring the stock spectator third person module, + // anchored to a stable location instead of animating bones: skeleton + // anchors make the camera bob violently with mount/riding animations. + camOrigin = TgP.Location; + if (int(TgP.c_LocalPolymorph) == 0) + camOrigin.Z += TgP.EyeHeight; + else + camOrigin.Z += 20.0; Offset.X = -142.0; Offset.Z = 25.0; - OutVT.POV.Location = camOrigin + (Offset >> CamRot) + TgP.WalkBob; - return; + TargetPOV.Location = camOrigin + (Offset >> CamRot); } - - // Prefer the rig's camera bone (same source as the native first person path), - // but only trust it when the rig was actually built near the pawn this frame. - if (TgP != none && WMA != none && WMA.m_bUseSocketForCameraAttach && WMA.CanUseCameraBone() && WMA.m_WeaponMesh1P != none) + else if (TgP != none) { - boneLoc = WMA.m_WeaponMesh1P.GetBoneLocation('Camera_bn'); + // Prefer the rig's camera bone (same source as the native first person + // path), but only trust it when the rig was built near the pawn. + boneLoc = vect(0.0, 0.0, 0.0); + if (WMA != none && WMA.m_bUseSocketForCameraAttach && WMA.CanUseCameraBone() && WMA.m_WeaponMesh1P != none) + boneLoc = WMA.m_WeaponMesh1P.GetBoneLocation('Camera_bn'); + if (VSizeSq(boneLoc - TgP.Location) < 90000.0) { - OutVT.POV.Location = boneLoc; - OutVT.POV.Rotation = OrthoRotation( + TargetPOV.Location = boneLoc; + TargetPOV.Rotation = OrthoRotation( WMA.m_WeaponMesh1P.GetBoneAxis('Camera_bn', 4) * -1.0, WMA.m_WeaponMesh1P.GetBoneAxis('Camera_bn', 1), WMA.m_WeaponMesh1P.GetBoneAxis('Camera_bn', 2) * -1.0); - return; + } + else + { + TargetPOV.Location = GetBodyAttachPoint(TgP) + TgP.WalkBob; } } + else if (OutVT.Target != none) + { + TargetPOV.Location = OutVT.Target.Location; + } - if (TgP != none) + // Keep the pipeline's FOV; only pose blends below. + TargetPOV.FOV = OutVT.POV.FOV; + + // Smooth pose transition, same math as TgCameraModule_SpectatorThirdPerson: + // blend from the frozen previous POV toward the moving target with a cubic + // ease while a flip is in flight, then track the target directly. + if (RemainingBlendTime > 0.0) { - camOrigin = GetBodyAttachPoint(TgP); - OutVT.POV.Location = camOrigin + TgP.WalkBob; + RemainingBlendTime -= DeltaTime; + if (RemainingBlendTime < 0.0) + RemainingBlendTime = 0.0; + DurationPct = (TotalBlendTime - RemainingBlendTime) / TotalBlendTime; + BlendPct = FCubicInterp(0.0, 0.0, 1.0, 0.0, DurationPct); + OutVT.POV.Location = VLerp(OldViewpoint.Location, TargetPOV.Location, BlendPct); + OutVT.POV.Rotation = RLerp(OldViewpoint.Rotation, TargetPOV.Rotation, BlendPct, true); + OutVT.POV.FOV = Lerp(OldViewpoint.FOV, TargetPOV.FOV, BlendPct); } - else if (OutVT.Target != none) + else { - OutVT.POV.Location = OutVT.Target.Location; + OutVT.POV = TargetPOV; } } diff --git a/Development/Src/TmCore/Classes/TmSpectatorController.uc b/Development/Src/TmCore/Classes/TmSpectatorController.uc index 8da1592d..67bdb35b 100644 --- a/Development/Src/TmCore/Classes/TmSpectatorController.uc +++ b/Development/Src/TmCore/Classes/TmSpectatorController.uc @@ -19,6 +19,17 @@ var transient int bBorderBuilt; // Pawn currently forced into the client-side first person rig, if any. var transient TgPawn m_NudgedFpPawn; +// Last known mounted state of the nudged pawn (edge detection for the +// manual mount presentation). +var transient bool m_bWasMounted; +// True while inside a mount/emote third person window. +var transient bool m_bWasWindow3P; +// Throttle for diagnostic logging. +var transient float m_fNextDiagTime; +// Set false to silence the periodic first person diagnostics. +var config bool c_bFpDiagLogs; +// If false, disable the server-side relevancy viewpoint override. +var config bool c_bTrackViewTargetForRelevancy; const ABILITY_SLOTS = 5; @@ -72,23 +83,23 @@ exec function SetSpectatorCameraMode(TgSpectatorController.SpectatorCameraMode M } // Opens the native first person gate (ATgPawn::ShouldBeFirstPersonThisTick, -// live path) for the followed pawn. The gate requires: -// Controller != none, IsA(TgPlayerController) and !Controller->Wants3P() -// A spectated pawn normally fails the first test because other players' -// controllers are never replicated to us. Assigning ourselves client-side is -// safe: Pawn.Controller is owner-only replicated so it never leaves this -// machine, and the real owner keeps authoritative control server-side. -// Wants3P() returns true when the current camera module derives from -// TgCameraModule_ThirdPerson (with zoom applied) or when a 3P camera posture -// is pushed, which is why our FP camera module must NOT derive from any -// ThirdPerson module and why we keep the posture cleared. With the gate open, -// the engine builds the entire native first person rig (viewmodel, Camera_bn -// eye position) and its device loop adapts FP<->3P during abilities/mounts -// exactly like the followed player sees. The spectated pawn's world model is -// hidden absolutely (bOwnerNoSee cannot work: Owner never replicates to us). +// live path) for the followed pawn by assigning the local spectator +// controller to its Controller reference client-side. Pawn.Controller is +// owner-only replicated so the assignment never leaves this machine and the +// real owner keeps authoritative control server-side. Attaching a LOCAL +// controller is deliberate: many effect playback paths branch on +// IsLocallyControlled(), and the local branches are the ones that reliably +// play tracers/impacts/explosions for the spectated pawn (non-local stand-in +// controllers suppress them). +// Wants3P() returns false for us because our FP camera module does not derive +// from TgCameraModule_ThirdPerson and no 3P camera posture is latched, which +// opens the gate and makes the engine build the entire native first person +// rig (viewmodel, Camera_bn eye position) exactly like the followed player +// sees. simulated function UpdateFirstPersonNudge() { local TgPawn ViewPawn; + local TgRepInfo_Player FollowedPRI; local bool bNativeFP; if (m_CameraMode != SpectatorCameraMode.SpecCam_FollowFirstPerson) @@ -111,20 +122,72 @@ simulated function UpdateFirstPersonNudge() m_NudgedFpPawn = ViewPawn; ViewPawn.Controller = self; + m_bBehindView = false; + + // Mounts have no native signal in a spectator context (their posture is + // pushed on the owning player's controller only, and the gate's device + // loop cannot see them), so detect them from replicated pawn state. The + // native r_bIsMounted replication handler also refuses to build the horse + // while the pawn looks locally controlled, so the mount presentation is + // driven manually - strictly on transitions, otherwise the dismount + // presentation would replay every frame. + if (ViewPawn.r_bIsMounted != m_bWasMounted) + { + m_bWasMounted = ViewPawn.r_bIsMounted; + if (ViewPawn.r_bIsMounted) + ViewPawn.PlayMountingEffects(false, ViewPawn.r_bUseMountPosture); + else + ViewPawn.StopMountingEffects(true, ViewPawn.r_bUseMountPosture); + } - // Single source of truth: ask the engine whether this pawn is first person - // this tick. The native device loop already accounts for abilities, mounts - // and emotes; our camera module just flips between the eye pose and the - // behind-view pose without ever leaving its class. - bNativeFP = ViewPawn.ShouldBeFirstPersonThisTick(); + bNativeFP = !ViewPawn.r_bIsMounted + && !IsForced3PAbilityDevice(ViewPawn) + && ViewPawn.ShouldBeFirstPersonThisTick(); m_bBehindView = !bNativeFP; SetRealModelHidden(ViewPawn, bNativeFP); + SetRigVisible(ViewPawn, bNativeFP); SetForced3PPose(!bNativeFP); +} + +// Some movement abilities never close the native gate: their third person +// window is driven by owning-client camera code rather than their form's +// force-3P bit (which is what the gate's device loop reads), and their brief +// fire state is easy to miss. Detect them explicitly while firing. +simulated function bool IsForced3PAbilityDevice(TgPawn ViewPawn) +{ + local int i, slot; + local int EquipSlots[5]; + local TgDevice Dev; + + EquipSlots[0] = 1; + EquipSlots[1] = 16; + EquipSlots[2] = 3; + EquipSlots[3] = 4; + EquipSlots[4] = 2; - // Wants3P() folds in the posture stack; make sure nothing 3P is latched. - if (int(c_eCameraPosture) != 0) - c_eCameraPosture = TG_CAMERAPOSTURE_None; + for (i = 0; i < ABILITY_SLOTS; i++) + { + slot = EquipSlots[i]; + Dev = ViewPawn.GetDeviceByEqPoint(slot); + if (Dev == none || !Dev.IsFiring()) + continue; + + if (Dev.IsA('TgDevice_HunterRoll') // Cassie dodge roll + || Dev.IsA('TgDevice_Flutter') // Willo flutter + || Dev.IsA('TgDevice_Thrust') + || Dev.IsA('TgDevice_CombatSlide') + || Dev.IsA('TgDevice_NetherStep') + || Dev.IsA('TgDevice_Advance') + || Dev.IsA('TgDevice_Withdraw') + || Dev.IsA('TgDevice_Pounce') + || Dev.IsA('TgDevice_Uppercut') + || Dev.IsA('TgDevice_HeroicLeap') + || Dev.IsA('TgDevice_KingBomb') + || Dev.IsA('TgDevice_Reversal')) + return true; + } + return false; } simulated function SetForced3PPose(bool bForced3P) @@ -135,17 +198,114 @@ simulated function SetForced3PPose(bool bForced3P) return; CamMod = TmCameraModule_SpectatorFirstPerson(TgPlayerCamera(PlayerCamera).CurrentCameraMod); if (CamMod != none) - CamMod.m_bForced3P = bForced3P; + CamMod.SetForced3P(bForced3P); } -// Hiding must be absolute: the pawn's Mesh uses bOwnerNoSee semantics against -// an Owner that is never replicated to spectators. +// Hiding must be absolute and survive ATgPawn::TickSpecial, which re-asserts +// mesh visibility every tick via RecursiveSetVisibility (derived from +// bOwnerNoSee-style flags that cannot work here: Owner is not replicated to +// spectators, so the engine never considers the pawn's meshes owned by us). +// Setting the actor-level bHidden wins that race: rendering honors the owning +// actor's hidden flag regardless of per-component flips. The mount mesh is +// deliberately skipped: PlayMountingEffects/StopMountingEffects own its +// lifecycle, and blanket-unhiding would resurrect a stale horse during later +// third person ticks. simulated function SetRealModelHidden(TgPawn ViewPawn, bool bHidden) { - if (ViewPawn.Mesh != none) - ViewPawn.Mesh.SetHidden(bHidden); - if (ViewPawn.m_WeaponMesh != none && ViewPawn.m_WeaponMesh.m_WeaponMesh3P != none) - ViewPawn.m_WeaponMesh.m_WeaponMesh3P.SetHidden(bHidden); + local PrimitiveComponent Prim; + + ViewPawn.SetHidden(bHidden); + foreach ViewPawn.ComponentList(class'PrimitiveComponent', Prim) + { + if (!bHidden && Prim == ViewPawn.m_MountMesh) + continue; + Prim.SetHidden(bHidden); + } +} + +// Park or restore the first person viewmodel for third person ticks. The +// native gate stays open there, so its per-tick toggles (OwnerNoSee based) +// keep flipping state; absolute SetHidden sticks instead. Overlay duplicates +// and particles attached to the rig are handled too, otherwise weapon glow +// effects keep rendering over a hidden mesh. +simulated function SetRigVisible(TgPawn ViewPawn, bool bVisible) +{ + local TgWeaponMeshActor WMA; + local PrimitiveComponent Prim; + local int i; + + WMA = ViewPawn.m_WeaponMesh; + if (WMA == none) + return; + + if (bVisible) + { + // Restore the first person pieces plus everything owned by the rig + // (weapon glow particles live here); the 3P weapon is left to the + // native owner-based visibility, which now applies to us. + foreach WMA.AllOwnedComponents(class'PrimitiveComponent', Prim) + Prim.SetHidden(false); + if (WMA.m_WeaponMesh1P != none) + WMA.m_WeaponMesh1P.FxActivateGroup('AlwaysOn', 0); + if (WMA.m_HandsMesh != none) + WMA.m_HandsMesh.FxActivateGroup('AlwaysOn', 0); + if (WMA.m_HeadMesh1P != none) + WMA.m_HeadMesh1P.FxActivateGroup('AlwaysOn', 0); + SetRigOverlaysVisible(ViewPawn, true); + return; + } + + // Park everything weapon related for third person ticks. + WMA.Set1PAttachState(2); + if (WMA.m_WeaponMesh1P != none) + { + WMA.m_WeaponMesh1P.SetHidden(true); + WMA.m_WeaponMesh1P.FxDeactivateGroup('AlwaysOn', 0); + } + if (WMA.m_HandsMesh != none) + { + WMA.m_HandsMesh.SetHidden(true); + WMA.m_HandsMesh.FxDeactivateGroup('AlwaysOn', 0); + } + if (WMA.m_HeadMesh1P != none) + { + WMA.m_HeadMesh1P.SetHidden(true); + WMA.m_HeadMesh1P.FxDeactivateGroup('AlwaysOn', 0); + } + if (WMA.m_WeaponMesh3P != none) + WMA.m_WeaponMesh3P.SetHidden(true); + + // Particles parented to the rig render independently of their parent's + // hidden flag, and they can live anywhere in the ownership chain (the + // weapon glows do), so sweep everything the rig owns. + foreach WMA.AllOwnedComponents(class'PrimitiveComponent', Prim) + Prim.SetHidden(true); + + SetRigOverlaysVisible(ViewPawn, false); +} + +// Hides/shows the overlay duplicate meshes parented to the first person +// pieces (skin/shield overlays render independently of their parent's flag). +simulated function SetRigOverlaysVisible(TgPawn ViewPawn, bool bVisible) +{ + local int i; + local TgWeaponMeshActor WMA; + + WMA = ViewPawn.m_WeaponMesh; + if (WMA == none) + return; + + for (i = 0; i < ViewPawn.m_OverlayInfosBody.Length; i++) + { + if (ViewPawn.m_OverlayInfosBody[i].ParentMesh == WMA.m_HandsMesh + || ViewPawn.m_OverlayInfosBody[i].ParentMesh == WMA.m_HeadMesh1P) + ViewPawn.m_OverlayInfosBody[i].OverlayMesh.SetHidden(!bVisible); + } + for (i = 0; i < ViewPawn.m_OverlayInfosWeapon.Length; i++) + { + if (ViewPawn.m_OverlayInfosWeapon[i].ParentMesh == WMA.m_WeaponMesh1P) + ViewPawn.m_OverlayInfosWeapon[i].OverlayMesh.SetHidden(!bVisible); + } } simulated function ClearFirstPersonNudge() @@ -157,7 +317,10 @@ simulated function ClearFirstPersonNudge() Nudged = m_NudgedFpPawn; m_NudgedFpPawn = none; + m_bWasMounted = false; + SetRealModelHidden(Nudged, false); + SetRigVisible(Nudged, true); SetForced3PPose(false); if (Nudged.Controller == self) Nudged.Controller = none; @@ -366,3 +529,9 @@ simulated function TickSpectatorTeamHUD() HealthTip.SetAlpha(100.0); } } + +defaultproperties +{ + c_bFpDiagLogs=true + c_bTrackViewTargetForRelevancy=true +} From 50e08b7f4e34e5252718e16a7f9cbe84e2f40f50 Mon Sep 17 00:00:00 2001 From: SupImCinnamon Date: Mon, 24 Aug 2026 20:10:27 +0200 Subject: [PATCH 3/4] Fixed 3P not working on some abilities --- .../TmCore/Classes/TmSpectatorController.uc | 461 +++++------------- 1 file changed, 123 insertions(+), 338 deletions(-) diff --git a/Development/Src/TmCore/Classes/TmSpectatorController.uc b/Development/Src/TmCore/Classes/TmSpectatorController.uc index 71131984..8506088e 100644 --- a/Development/Src/TmCore/Classes/TmSpectatorController.uc +++ b/Development/Src/TmCore/Classes/TmSpectatorController.uc @@ -15,15 +15,14 @@ var transient int bBorderBuilt; // Pawn currently forced into the client-side first person rig, if any. var transient TgPawn m_NudgedFpPawn; +// Task force of the followed player (team-relative rendering follows it). +var transient int m_nFollowedTaskForce; + // Last known mounted state of the nudged pawn (edge detection for the // manual mount presentation). var transient bool m_bWasMounted; // True while inside a mount/emote third person window. var transient bool m_bWasWindow3P; -// Throttle for diagnostic logging. -var transient float m_fNextDiagTime; -// Set false to silence the periodic first person diagnostics. -var config bool c_bFpDiagLogs; // If false, disable the server-side relevancy viewpoint override. var config bool c_bTrackViewTargetForRelevancy; @@ -114,6 +113,18 @@ simulated function UpdateFirstPersonNudge() ViewPawn.Controller = self; m_bBehindView = false; + // Outline colors, nameplates and friendly/enemy classification resolve + // through the controller's PRI now that we are attached. Follow the + // viewed player's task force so the spectator sees teams exactly like + // they do. + FollowedPRI = TgRepInfo_Player(ViewPawn.PlayerReplicationInfo); + if (FollowedPRI != none && CachedPRI != none + && int(FollowedPRI.GetTaskForceNumber()) != m_nFollowedTaskForce) + { + m_nFollowedTaskForce = int(FollowedPRI.GetTaskForceNumber()); + CachedPRI.SetTaskForceNumber(byte(m_nFollowedTaskForce)); + } + // Mounts have no native signal in a spectator context (their posture is // pushed on the owning player's controller only, and the gate's device // loop cannot see them), so detect them from replicated pawn state. The @@ -140,46 +151,99 @@ simulated function UpdateFirstPersonNudge() SetForced3PPose(!bNativeFP); } -// Some movement abilities never close the native gate: their third person -// window is driven by owning-client camera code rather than their form's -// force-3P bit (which is what the gate's device loop reads), and their brief -// fire state is easy to miss. Detect them explicitly while firing. +//TODO Cinnamon: Find a better way to do this + +// Some movement abilities never close the native gate, and DEVICE OBJECTS do +// not replicate to spectators at all (GetDeviceByEqPoint resolves to none on +// this connection - verified by diagnostics). What does replicate is the form +// STATE NAME per equip point plus the pawn class itself, so third person +// windows are driven by a per-champion slot table. +// +// Champion internal class names: Cassie=TgPawn_Cassie, Lian=TgPawn_Princess, +// Ash=TgPawn_Juggernaut(unverified), Barik=TgPawn_Barik, +// Fernando=TgPawn_Fernando, Makoa=TgPawn_Makoa, Androxus=TgPawn_Androxus, +// Evie=TgPawn_Evie, Zhin=TgPawn_Darklord, Grohk=TgPawn_Grohk, +// Maeve=TgPawn_Blades, Willo=TgPawn_Fairy, Sha Lin=TgPawn_Longbow, +// Seris/Torvald=TgPawn_Oracle(?). simulated function bool IsForced3PAbilityDevice(TgPawn ViewPawn) { - local int i, slot; - local int EquipSlots[5]; - local TgDevice Dev; - - EquipSlots[0] = 1; - EquipSlots[1] = 16; - EquipSlots[2] = 3; - EquipSlots[3] = 4; - EquipSlots[4] = 2; + local int Mask; + + // Bit 4 = F ability, bit 3 = Q ability, bit 2 = E ultimate. + Mask = GetForce3PSlotMask(ViewPawn) | GetForce3PUltSlotMask(ViewPawn); + if (Mask == 0) + return false; + + if ((Mask & (1 << 4)) != 0 && ViewPawn.c_EquipFormState[4] == 'DeviceFiring') + return true; + if ((Mask & (1 << 3)) != 0 && ViewPawn.c_EquipFormState[3] == 'DeviceFiring') + return true; + if ((Mask & (1 << 2)) != 0 && ViewPawn.c_EquipFormState[2] == 'DeviceFiring') + return true; + return false; +} - for (i = 0; i < ABILITY_SLOTS; i++) - { - slot = EquipSlots[i]; - Dev = ViewPawn.GetDeviceByEqPoint(slot); - if (Dev == none || !Dev.IsFiring()) - continue; +//TODO Cinnamon: Find a better way to do this - if (Dev.IsA('TgDevice_HunterRoll') // Cassie dodge roll - || Dev.IsA('TgDevice_Flutter') // Willo flutter - || Dev.IsA('TgDevice_Thrust') - || Dev.IsA('TgDevice_CombatSlide') - || Dev.IsA('TgDevice_NetherStep') - || Dev.IsA('TgDevice_Advance') - || Dev.IsA('TgDevice_Withdraw') - || Dev.IsA('TgDevice_Pounce') - || Dev.IsA('TgDevice_Uppercut') - || Dev.IsA('TgDevice_HeroicLeap') - || Dev.IsA('TgDevice_KingBomb') - || Dev.IsA('TgDevice_Reversal')) - return true; - } - return false; +// Ultimates (equip point 2) that play out in third person. Jenos' Through +// Time and Space already closes the gate natively and needs no entry. +simulated function int GetForce3PUltSlotMask(TgPawn ViewPawn) +{ + if (ViewPawn.IsA('TgPawn_Princess') // Lian + || ViewPawn.IsA('TgPawn_Flak') // Ash + || ViewPawn.IsA('TgPawn_Knight') // Fernando + || ViewPawn.IsA('TgPawn_Darklord') // Zhin + || ViewPawn.IsA('TgPawn_Shaman') // Grohk + || ViewPawn.IsA('TgPawn_BombKing') // Bomb King + || ViewPawn.IsA('TgPawn_Drogoz') // Drogoz + || ViewPawn.IsA('TgPawn_BarrierTank') // Inara + || ViewPawn.IsA('TgPawn_Druid')) // Grover + return 1 << 2; + + return 0; } + +//TODO Cinnamon: Find a better way to do this +simulated function int GetForce3PSlotMask(TgPawn ViewPawn) +{ + // Champion internal class names (runtime): Cassie=Huntress, + // Fernando=Knight, Barik=Engineer, Grohk=Shaman, Evie=Mage, + // Lian=Princess, Ash=Flak, Makoa=Makoa, Androxus=Androxus, + // Zhin=Darklord, Maeve=Blades, Willo=Fairy, Sha Lin=Longbow, + // Seris=Oracle, Strix=Owl, Inara=BarrierTank, Terminus=Lazarus, + // Tyra=Salty, Viktor=BRmale/Robosarge, Pip=Alchemist, Vivian=Churchill, + // Moji=Rider, Lex=Lawman, Grover=Druid, Atlas=TimeTraveler, + // Koga=Ninja, Furia=Angel, Dredge=Pirate, Ying=Illusionist, + // Khan=Vanguard. Old export aliases kept as fallbacks where they exist. + + // Fernando and Zhin force third person on both F and Q. + if (ViewPawn.IsA('TgPawn_Knight') || ViewPawn.IsA('TgPawn_Fernando') + || ViewPawn.IsA('TgPawn_Darklord')) + return (1 << 4) | (1 << 3); + + // Androxus forces it on Q only. + if (ViewPawn.IsA('TgPawn_Androxus')) + return (1 << 3); + + // Everyone else with a forcing ability uses F only. + if (ViewPawn.IsA('TgPawn_Huntress') // Cassie dodge roll + || ViewPawn.IsA('TgPawn_Cassie') // (old alias) + || ViewPawn.IsA('TgPawn_Princess') // Lian + || ViewPawn.IsA('TgPawn_Flak') // Ash + || ViewPawn.IsA('TgPawn_Engineer') // Barik + || ViewPawn.IsA('TgPawn_Barik') // (old alias) + || ViewPawn.IsA('TgPawn_Makoa') // Makoa + || ViewPawn.IsA('TgPawn_Shaman') // Grohk + || ViewPawn.IsA('TgPawn_Grohk') // (old alias) + || ViewPawn.IsA('TgPawn_Mage') // Evie + || ViewPawn.IsA('TgPawn_Evie')) // (old alias) + return (1 << 4); + + // Tyra, Viktor, Sha Lin, Bomb King, Drogoz, Kinessa, Inara, Ruckus, + // Torvald, Buck, Lex, Maeve, Skye, Grover, Seris, Ying, Strix: none. + return 0; +} simulated function SetForced3PPose(bool bForced3P) { local TmCameraModule_SpectatorFirstPerson CamMod; @@ -298,306 +362,30 @@ simulated function SetRigOverlaysVisible(TgPawn ViewPawn, bool bVisible) } } -simulated function ClearFirstPersonNudge() -{ - local TgPawn Nudged; - - if (m_NudgedFpPawn == none) - return; - - Nudged = m_NudgedFpPawn; - m_NudgedFpPawn = none; - m_bWasMounted = false; - - SetRealModelHidden(Nudged, false); - SetRigVisible(Nudged, true); - SetForced3PPose(false); - if (Nudged.Controller == self) - Nudged.Controller = none; - m_bBehindView = true; -} - -exec function SpecToggleFirstPerson() -{ - if (int(m_CameraMode) == int(SpectatorCameraMode.SpecCam_FollowFirstPerson)) - SetSpectatorCameraMode(SpectatorCameraMode.SpecCam_FollowThirdPerson); - else - SetSpectatorCameraMode(SpectatorCameraMode.SpecCam_FollowFirstPerson); -} - -// Same bookkeeping as the stock mode 3 handler, but swaps in our own camera -// module so the POV is driven from script instead of the native gate. -exec function SetSpectatorCameraMode(TgSpectatorController.SpectatorCameraMode Mode, optional bool bCameraTween = false) -{ - if (int(Mode) != int(SpectatorCameraMode.SpecCam_FollowFirstPerson)) - { - super.SetSpectatorCameraMode(Mode, bCameraTween); - ClearFirstPersonNudge(); - return; - } - - if (int(Mode) == int(m_CameraMode)) - return; - - m_CameraMode = Mode; - m_bIsMapSquashed = false; - if (bCameraTween) - TgPlayerCamera(PlayerCamera).SwitchCameras(class'TmCore.TmCameraModule_SpectatorFirstPerson', 0.2); - else - TgPlayerCamera(PlayerCamera).SwitchCameras(class'TmCore.TmCameraModule_SpectatorFirstPerson'); -} - -// Opens the native first person gate (ATgPawn::ShouldBeFirstPersonThisTick, -// live path) for the followed pawn by assigning the local spectator -// controller to its Controller reference client-side. Pawn.Controller is -// owner-only replicated so the assignment never leaves this machine and the -// real owner keeps authoritative control server-side. Attaching a LOCAL -// controller is deliberate: many effect playback paths branch on -// IsLocallyControlled(), and the local branches are the ones that reliably -// play tracers/impacts/explosions for the spectated pawn (non-local stand-in -// controllers suppress them). -// Wants3P() returns false for us because our FP camera module does not derive -// from TgCameraModule_ThirdPerson and no 3P camera posture is latched, which -// opens the gate and makes the engine build the entire native first person -// rig (viewmodel, Camera_bn eye position) exactly like the followed player -// sees. -simulated function UpdateFirstPersonNudge() -{ - local TgPawn ViewPawn; - local TgRepInfo_Player FollowedPRI; - local bool bNativeFP; - - if (m_CameraMode != SpectatorCameraMode.SpecCam_FollowFirstPerson) - { - ClearFirstPersonNudge(); - return; - } - - ViewPawn = TgPawn(GetViewTarget()); - // Never touch pawns we have authority over (listen server): the fake - // Controller assignment is only safe on simulated proxies. - if (ViewPawn == none || !ViewPawn.IsAliveAndWell() || ViewPawn.Role >= ROLE_Authority) - { - ClearFirstPersonNudge(); - return; - } - - if (m_NudgedFpPawn != none && m_NudgedFpPawn != ViewPawn) - ClearFirstPersonNudge(); - - m_NudgedFpPawn = ViewPawn; - ViewPawn.Controller = self; - m_bBehindView = false; - - // Mounts have no native signal in a spectator context (their posture is - // pushed on the owning player's controller only, and the gate's device - // loop cannot see them), so detect them from replicated pawn state. The - // native r_bIsMounted replication handler also refuses to build the horse - // while the pawn looks locally controlled, so the mount presentation is - // driven manually - strictly on transitions, otherwise the dismount - // presentation would replay every frame. - if (ViewPawn.r_bIsMounted != m_bWasMounted) - { - m_bWasMounted = ViewPawn.r_bIsMounted; - if (ViewPawn.r_bIsMounted) - ViewPawn.PlayMountingEffects(false, ViewPawn.r_bUseMountPosture); - else - ViewPawn.StopMountingEffects(true, ViewPawn.r_bUseMountPosture); - } - - bNativeFP = !ViewPawn.r_bIsMounted - && !IsForced3PAbilityDevice(ViewPawn) - && ViewPawn.ShouldBeFirstPersonThisTick(); - m_bBehindView = !bNativeFP; - - SetRealModelHidden(ViewPawn, bNativeFP); - SetRigVisible(ViewPawn, bNativeFP); - SetForced3PPose(!bNativeFP); -} - -// Some movement abilities never close the native gate: their third person -// window is driven by owning-client camera code rather than their form's -// force-3P bit (which is what the gate's device loop reads), and their brief -// fire state is easy to miss. Detect them explicitly while firing. -simulated function bool IsForced3PAbilityDevice(TgPawn ViewPawn) -{ - local int i, slot; - local int EquipSlots[5]; - local TgDevice Dev; - - EquipSlots[0] = 1; - EquipSlots[1] = 16; - EquipSlots[2] = 3; - EquipSlots[3] = 4; - EquipSlots[4] = 2; - - for (i = 0; i < ABILITY_SLOTS; i++) - { - slot = EquipSlots[i]; - Dev = ViewPawn.GetDeviceByEqPoint(slot); - if (Dev == none || !Dev.IsFiring()) - continue; - - if (Dev.IsA('TgDevice_HunterRoll') // Cassie dodge roll - || Dev.IsA('TgDevice_Flutter') // Willo flutter - || Dev.IsA('TgDevice_Thrust') - || Dev.IsA('TgDevice_CombatSlide') - || Dev.IsA('TgDevice_NetherStep') - || Dev.IsA('TgDevice_Advance') - || Dev.IsA('TgDevice_Withdraw') - || Dev.IsA('TgDevice_Pounce') - || Dev.IsA('TgDevice_Uppercut') - || Dev.IsA('TgDevice_HeroicLeap') - || Dev.IsA('TgDevice_KingBomb') - || Dev.IsA('TgDevice_Reversal')) - return true; - } - return false; -} - -simulated function SetForced3PPose(bool bForced3P) -{ - local TmCameraModule_SpectatorFirstPerson CamMod; - - if (PlayerCamera == none) - return; - CamMod = TmCameraModule_SpectatorFirstPerson(TgPlayerCamera(PlayerCamera).CurrentCameraMod); - if (CamMod != none) - CamMod.SetForced3P(bForced3P); -} - -simulated function TickBurnsHud() -{ - local UIHudCards CardsHUD; - local UIHudBurns BurnHUD; - local TgPawn ViewPawn; - local TgRepInfo_Player ViewPRI; - local int DeviceIds[4], Powers[4], i; - - CardsHUD = UIHudCards(`UTILS.FindSceneByClassName(TgGameHUD(myHUD), 'UIHudCards')); - if (CardsHUD == none) - { - `log("BurnHud: no UIHudCards",,'TmSpec'); - return; - } - BurnHUD = UIHudBurns(`UTILS.FindSceneByClassName(TgGameHUD(myHUD), 'UIHudBurns')); - - ViewPawn = TgPawn(GetViewTarget()); - if (ViewPawn != none) - ViewPRI = TgRepInfo_Player(ViewPawn.PlayerReplicationInfo); - if (ViewPRI == none) - ViewPRI = TgRepInfo_Player(PlayerReplicationInfo); - - `UIUTILS.GetSpectatedBurnIds(ViewPRI, ViewPawn, DeviceIds, Powers); - - `UIUTILS.SyncBurnsToScene(CardsHUD, BurnHUD, DeviceIds, Powers); -} - -// Hiding must be absolute and survive ATgPawn::TickSpecial, which re-asserts -// mesh visibility every tick via RecursiveSetVisibility (derived from -// bOwnerNoSee-style flags that cannot work here: Owner is not replicated to -// spectators, so the engine never considers the pawn's meshes owned by us). -// Setting the actor-level bHidden wins that race: rendering honors the owning -// actor's hidden flag regardless of per-component flips. The mount mesh is -// deliberately skipped: PlayMountingEffects/StopMountingEffects own its -// lifecycle, and blanket-unhiding would resurrect a stale horse during later -// third person ticks. -simulated function SetRealModelHidden(TgPawn ViewPawn, bool bHidden) -{ - local PrimitiveComponent Prim; - - ViewPawn.SetHidden(bHidden); - foreach ViewPawn.ComponentList(class'PrimitiveComponent', Prim) - { - if (!bHidden && Prim == ViewPawn.m_MountMesh) - continue; - Prim.SetHidden(bHidden); - } -} - -// Park or restore the first person viewmodel for third person ticks. The -// native gate stays open there, so its per-tick toggles (OwnerNoSee based) -// keep flipping state; absolute SetHidden sticks instead. Overlay duplicates -// and particles attached to the rig are handled too, otherwise weapon glow -// effects keep rendering over a hidden mesh. -simulated function SetRigVisible(TgPawn ViewPawn, bool bVisible) +// Server-side actor relevancy (projectiles, deployables, sounds...) is computed +// from each controller's GetPlayerViewPoint. Flag-gated override that tracks +// the view target; kept off the hot path when disabled. +simulated event GetPlayerViewPoint(out Vector out_Location, out Rotator out_Rotation) { - local TgWeaponMeshActor WMA; - local PrimitiveComponent Prim; - local int i; - - WMA = ViewPawn.m_WeaponMesh; - if (WMA == none) - return; + local Actor VT; - if (bVisible) - { - // Restore the first person pieces plus everything owned by the rig - // (weapon glow particles live here); the 3P weapon is left to the - // native owner-based visibility, which now applies to us. - foreach WMA.AllOwnedComponents(class'PrimitiveComponent', Prim) - Prim.SetHidden(false); - if (WMA.m_WeaponMesh1P != none) - WMA.m_WeaponMesh1P.FxActivateGroup('AlwaysOn', 0); - if (WMA.m_HandsMesh != none) - WMA.m_HandsMesh.FxActivateGroup('AlwaysOn', 0); - if (WMA.m_HeadMesh1P != none) - WMA.m_HeadMesh1P.FxActivateGroup('AlwaysOn', 0); - SetRigOverlaysVisible(ViewPawn, true); - return; - } - - // Park everything weapon related for third person ticks. - WMA.Set1PAttachState(2); - if (WMA.m_WeaponMesh1P != none) - { - WMA.m_WeaponMesh1P.SetHidden(true); - WMA.m_WeaponMesh1P.FxDeactivateGroup('AlwaysOn', 0); - } - if (WMA.m_HandsMesh != none) - { - WMA.m_HandsMesh.SetHidden(true); - WMA.m_HandsMesh.FxDeactivateGroup('AlwaysOn', 0); - } - if (WMA.m_HeadMesh1P != none) + if (c_bTrackViewTargetForRelevancy && Role == ROLE_Authority && WorldInfo.NetMode != NM_Client) { - WMA.m_HeadMesh1P.SetHidden(true); - WMA.m_HeadMesh1P.FxDeactivateGroup('AlwaysOn', 0); + VT = GetViewTarget(); + if (VT != none) + { + out_Location = VT.Location; + out_Rotation = VT.Rotation; + return; + } } - if (WMA.m_WeaponMesh3P != none) - WMA.m_WeaponMesh3P.SetHidden(true); - - // Particles parented to the rig render independently of their parent's - // hidden flag, and they can live anywhere in the ownership chain (the - // weapon glows do), so sweep everything the rig owns. - foreach WMA.AllOwnedComponents(class'PrimitiveComponent', Prim) - Prim.SetHidden(true); - - SetRigOverlaysVisible(ViewPawn, false); + super.GetPlayerViewPoint(out_Location, out_Rotation); } -// Hides/shows the overlay duplicate meshes parented to the first person -// pieces (skin/shield overlays render independently of their parent's flag). -simulated function SetRigOverlaysVisible(TgPawn ViewPawn, bool bVisible) +function SpectatorSetViewTarget(Actor VT, optional ViewTargetTransitionParams TransitionParams) { - local int i; - local TgWeaponMeshActor WMA; - - WMA = ViewPawn.m_WeaponMesh; - if (WMA == none) - return; - - for (i = 0; i < ViewPawn.m_OverlayInfosBody.Length; i++) - { - if (ViewPawn.m_OverlayInfosBody[i].ParentMesh == WMA.m_HandsMesh - || ViewPawn.m_OverlayInfosBody[i].ParentMesh == WMA.m_HeadMesh1P) - ViewPawn.m_OverlayInfosBody[i].OverlayMesh.SetHidden(!bVisible); - } - for (i = 0; i < ViewPawn.m_OverlayInfosWeapon.Length; i++) - { - if (ViewPawn.m_OverlayInfosWeapon[i].ParentMesh == WMA.m_WeaponMesh1P) - ViewPawn.m_OverlayInfosWeapon[i].OverlayMesh.SetHidden(!bVisible); - } + ClearFirstPersonNudge(); + super.SpectatorSetViewTarget(VT, TransitionParams); } simulated function ClearFirstPersonNudge() @@ -610,6 +398,10 @@ simulated function ClearFirstPersonNudge() Nudged = m_NudgedFpPawn; m_NudgedFpPawn = none; m_bWasMounted = false; + // Restore the spectator's default task force. + if (m_nFollowedTaskForce != 0 && CachedPRI != none) + CachedPRI.SetTaskForceNumber(1); + m_nFollowedTaskForce = 0; SetRealModelHidden(Nudged, false); SetRigVisible(Nudged, true); @@ -619,12 +411,6 @@ simulated function ClearFirstPersonNudge() m_bBehindView = true; } -function SpectatorSetViewTarget(Actor VT, optional ViewTargetTransitionParams TransitionParams) -{ - ClearFirstPersonNudge(); - super.SpectatorSetViewTarget(VT, TransitionParams); -} - simulated function TickBurnsHud() { local UIHudCards CardsHUD; @@ -777,6 +563,5 @@ simulated function TickSpectatorTeamHUD() defaultproperties { - c_bFpDiagLogs=true c_bTrackViewTargetForRelevancy=true } From 4d3f428d255e6c31118c9e560e5ea80c24eae280 Mon Sep 17 00:00:00 2001 From: SupImCinnamon Date: Wed, 26 Aug 2026 19:35:30 +0200 Subject: [PATCH 4/4] Fixed emotes --- .../Src/TmCore/Classes/TmDemoRecSpectator.uc | 175 ------------------ .../TmCore/Classes/TmSpectatorController.uc | 53 +++--- 2 files changed, 22 insertions(+), 206 deletions(-) delete mode 100644 Development/Src/TmCore/Classes/TmDemoRecSpectator.uc diff --git a/Development/Src/TmCore/Classes/TmDemoRecSpectator.uc b/Development/Src/TmCore/Classes/TmDemoRecSpectator.uc deleted file mode 100644 index 041c7e3a..00000000 --- a/Development/Src/TmCore/Classes/TmDemoRecSpectator.uc +++ /dev/null @@ -1,175 +0,0 @@ -class TmDemoRecSpectator extends TgDemoRecSpectator - config(Game) - hidecategories(Navigation) - dependson(TgObject); - -// Mirrored into the demo file so playback can restore the exact POV. -// ViewTarget is not a replicated property in UE3 and m_CameraMode is -// transient, so without this the replayed demo spectator sits at world -// origin in free-fly mode. This is also why the first person rig never -// builds on playback: TgPawn.IsFirstPerson() requires the local spectator -// controller to have GetViewTarget() == pawn && m_CameraMode == SpecCam_FollowFirstPerson. -var repnotify Actor m_rDemoViewTarget; -var repnotify byte m_rDemoCameraMode; - -var transient bool m_bPendingViewTarget; -var transient bool m_bPendingCameraMode; - -simulated event PostBeginPlay() -{ - // Intentionally skipping TgDemoRecSpectator.PostBeginPlay(): - // it registers engine callbacks, starts the MCTS spectator master/slave - // sync and the auto combat log. None of that can work on a bare dedicated - // server and the master/slave sync stalls normal client replication while - // a recording is active. Everything else in the chain still runs. - super(TgSpectatorController).PostBeginPlay(); - - if (Role == ROLE_Authority && WorldInfo.NetMode != NM_Client) - { - SetTimer(0.5, true, 'TmRecordTick'); - } - else if (WorldInfo.IsRecordingDemo()) - { - // Client-side recording: mirror what the human spectator is watching. - SetTimer(0.25, true, 'TmMirrorLocalSpectator'); - } - else if (WorldInfo.IsPlayingDemo()) - { - SetTimer(0.25, true, 'TmApplyRecordedState'); - } -} - -// Client-side recording variant: copy the local player's spectator state -// into the replicated mirror so playback restores the same POV. -function TmMirrorLocalSpectator() -{ - local TgSpectatorController Spec; - local Actor VT; - - if (!WorldInfo.IsRecordingDemo()) - return; - - Spec = TgSpectatorController(GetALocalPlayerController()); - if (Spec == none || Spec == self) - return; - - VT = Spec.GetViewTarget(); - if (VT != none && VT != m_rDemoViewTarget) - m_rDemoViewTarget = VT; - - if (Spec.m_CameraMode != m_rDemoCameraMode) - m_rDemoCameraMode = Spec.m_CameraMode; -} - -// Recording side housekeeping: flag PRIs so tracked projectile data gets -// replicated into the stream, keep an alive viewtarget, and mirror the -// current POV state into the replicated properties. -function TmRecordTick() -{ - local TgRepInfo_Player PRI; - local TgPawn_Character P; - local Actor VT; - local bool bFound; - - if (!WorldInfo.IsRecordingDemo()) - return; - - foreach WorldInfo.AllActors(Class'TgGame.TgRepInfo_Player', PRI) - { - if (!PRI.bDemoOwner) - PRI.bDemoOwner = true; - } - - VT = GetViewTarget(); - if (VT == none || TgPawn(VT) == none || !TgPawn(VT).IsAliveAndWell() || VT.IsInState('Dying')) - { - foreach WorldInfo.AllPawns(Class'TgGame.TgPawn_Character', P) - { - if (P.IsAliveAndWell() && !P.IsInState('Dying')) - { - SpectatorSetViewTarget(P); - SetSpectatorCameraMode(SpectatorCameraMode.SpecCam_FollowThirdPerson); - bFound = true; - break; - } - } - if (!bFound) - return; - } - - VT = GetViewTarget(); - if (VT != none && VT != m_rDemoViewTarget) - m_rDemoViewTarget = VT; - - if (m_CameraMode != m_rDemoCameraMode) - m_rDemoCameraMode = m_CameraMode; -} - -simulated event ReplicatedEvent(name VarName) -{ - if (VarName == 'm_rDemoViewTarget') - { - m_bPendingViewTarget = m_rDemoViewTarget != none; - return; - } - - if (VarName == 'm_rDemoCameraMode') - { - m_bPendingCameraMode = true; - return; - } - - super.ReplicatedEvent(VarName); -} - -// Playback side: restore what was being watched. Uses the native -// SetViewTarget/SetSpectatorCameraMode directly instead of -// SpectatorSetViewTarget to avoid firing server RPCs into the void. -simulated function TmApplyRecordedState() -{ - if (!WorldInfo.IsPlayingDemo()) - { - ClearTimer('TmApplyRecordedState'); - return; - } - - if (m_bPendingViewTarget && m_rDemoViewTarget != none && GetViewTarget() != m_rDemoViewTarget) - { - SetViewTarget(m_rDemoViewTarget); - m_bPendingViewTarget = false; - } - - if (m_bPendingCameraMode && PlayerCamera != none && int(m_CameraMode) != int(m_rDemoCameraMode)) - { - //SetSpectatorCameraMode(m_rDemoCameraMode, false); - m_bPendingCameraMode = false; - } -} - -// While recording, make the server-side viewpoint track the followed pawn. -// The stock implementation spawns PlayerCamera whose cache is never updated -// outside of rendering, so every viewer query (FNetViewer, relevancy traces, -// viewer-dependent replication conditions) reads world origin. -simulated event GetPlayerViewPoint(out Vector out_Location, out Rotator out_Rotation) -{ - local Actor VT; - - if (Role == ROLE_Authority && WorldInfo.NetMode != NM_Client) - { - VT = GetViewTarget(); - if (VT != none) - { - out_Location = VT.Location; - out_Rotation = VT.Rotation; - return; - } - } - super.GetPlayerViewPoint(out_Location, out_Rotation); -} - -defaultproperties -{ - RemoteRole=ROLE_AutonomousProxy - bDemoOwner=true - bAlwaysTick=true -} diff --git a/Development/Src/TmCore/Classes/TmSpectatorController.uc b/Development/Src/TmCore/Classes/TmSpectatorController.uc index 8506088e..7e32757b 100644 --- a/Development/Src/TmCore/Classes/TmSpectatorController.uc +++ b/Development/Src/TmCore/Classes/TmSpectatorController.uc @@ -15,11 +15,7 @@ var transient int bBorderBuilt; // Pawn currently forced into the client-side first person rig, if any. var transient TgPawn m_NudgedFpPawn; -// Task force of the followed player (team-relative rendering follows it). -var transient int m_nFollowedTaskForce; - -// Last known mounted state of the nudged pawn (edge detection for the -// manual mount presentation). +// Last known mounted state of the nudged pawn (edge detection for the manual mount presentation). var transient bool m_bWasMounted; // True while inside a mount/emote third person window. var transient bool m_bWasWindow3P; @@ -113,18 +109,6 @@ simulated function UpdateFirstPersonNudge() ViewPawn.Controller = self; m_bBehindView = false; - // Outline colors, nameplates and friendly/enemy classification resolve - // through the controller's PRI now that we are attached. Follow the - // viewed player's task force so the spectator sees teams exactly like - // they do. - FollowedPRI = TgRepInfo_Player(ViewPawn.PlayerReplicationInfo); - if (FollowedPRI != none && CachedPRI != none - && int(FollowedPRI.GetTaskForceNumber()) != m_nFollowedTaskForce) - { - m_nFollowedTaskForce = int(FollowedPRI.GetTaskForceNumber()); - CachedPRI.SetTaskForceNumber(byte(m_nFollowedTaskForce)); - } - // Mounts have no native signal in a spectator context (their posture is // pushed on the owning player's controller only, and the gate's device // loop cannot see them), so detect them from replicated pawn state. The @@ -141,6 +125,7 @@ simulated function UpdateFirstPersonNudge() ViewPawn.StopMountingEffects(true, ViewPawn.r_bUseMountPosture); } + bNativeFP = !ViewPawn.r_bIsMounted && !IsForced3PAbilityDevice(ViewPawn) && ViewPawn.ShouldBeFirstPersonThisTick(); @@ -169,16 +154,22 @@ simulated function bool IsForced3PAbilityDevice(TgPawn ViewPawn) { local int Mask; - // Bit 4 = F ability, bit 3 = Q ability, bit 2 = E ultimate. + //Cinnamon: 7 = emote. Handle emotes before anything else. + if (ViewPawn.c_EquipFormState[7] == 'DeviceFiring' || ViewPawn.c_EquipFormState[7] == 'DeviceBuilding') + return true; + + // Bit 16 = RMB ability, bit 4 = F ability, bit 3 = Q ability, bit 2 = E ability. Mask = GetForce3PSlotMask(ViewPawn) | GetForce3PUltSlotMask(ViewPawn); if (Mask == 0) return false; - - if ((Mask & (1 << 4)) != 0 && ViewPawn.c_EquipFormState[4] == 'DeviceFiring') + + if ((Mask & (1 << 16)) != 0 && (ViewPawn.c_EquipFormState[16] == 'DeviceFiring' || ViewPawn.c_EquipFormState[16] == 'DeviceBuilding')) return true; - if ((Mask & (1 << 3)) != 0 && ViewPawn.c_EquipFormState[3] == 'DeviceFiring') + if ((Mask & (1 << 4)) != 0 && (ViewPawn.c_EquipFormState[4] == 'DeviceFiring' || ViewPawn.c_EquipFormState[4] == 'DeviceBuilding')) return true; - if ((Mask & (1 << 2)) != 0 && ViewPawn.c_EquipFormState[2] == 'DeviceFiring') + if ((Mask & (1 << 3)) != 0 && (ViewPawn.c_EquipFormState[3] == 'DeviceFiring' || ViewPawn.c_EquipFormState[3] == 'DeviceBuilding')) + return true; + if ((Mask & (1 << 2)) != 0 && (ViewPawn.c_EquipFormState[2] == 'DeviceFiring' || ViewPawn.c_EquipFormState[2] == 'DeviceBuilding')) return true; return false; } @@ -217,11 +208,14 @@ simulated function int GetForce3PSlotMask(TgPawn ViewPawn) // Koga=Ninja, Furia=Angel, Dredge=Pirate, Ying=Illusionist, // Khan=Vanguard. Old export aliases kept as fallbacks where they exist. - // Fernando and Zhin force third person on both F and Q. - if (ViewPawn.IsA('TgPawn_Knight') || ViewPawn.IsA('TgPawn_Fernando') - || ViewPawn.IsA('TgPawn_Darklord')) + // Zhin forces third person on both F and Q. + if (ViewPawn.IsA('TgPawn_Darklord')) return (1 << 4) | (1 << 3); + // Fernando forces it on F and RMB + if(ViewPawn.IsA('TgPawn_Fernando') || ViewPawn.IsA('TgPawn_Knight')) + return (1 << 4) | (1 << 16); + // Androxus forces it on Q only. if (ViewPawn.IsA('TgPawn_Androxus')) return (1 << 3); @@ -237,7 +231,8 @@ simulated function int GetForce3PSlotMask(TgPawn ViewPawn) || ViewPawn.IsA('TgPawn_Shaman') // Grohk || ViewPawn.IsA('TgPawn_Grohk') // (old alias) || ViewPawn.IsA('TgPawn_Mage') // Evie - || ViewPawn.IsA('TgPawn_Evie')) // (old alias) + || ViewPawn.IsA('TgPawn_Evie') // (old alias) + || ViewPawn.IsA('TgPawn_Fairy')) // Willo return (1 << 4); // Tyra, Viktor, Sha Lin, Bomb King, Drogoz, Kinessa, Inara, Ruckus, @@ -398,10 +393,6 @@ simulated function ClearFirstPersonNudge() Nudged = m_NudgedFpPawn; m_NudgedFpPawn = none; m_bWasMounted = false; - // Restore the spectator's default task force. - if (m_nFollowedTaskForce != 0 && CachedPRI != none) - CachedPRI.SetTaskForceNumber(1); - m_nFollowedTaskForce = 0; SetRealModelHidden(Nudged, false); SetRigVisible(Nudged, true); @@ -564,4 +555,4 @@ simulated function TickSpectatorTeamHUD() defaultproperties { c_bTrackViewTargetForRelevancy=true -} +} \ No newline at end of file