enum ERR_ObserverReason { NONE, SPENT_LIFE, LATE_EXTRACTION, SPAWN_UNAVAILABLE, UNCONSCIOUS } class ERR_ObserverRules { static int Cycle(bool enabled, bool left, bool right, bool previousLeft, bool previousRight) { if (!enabled || left == right) return 0; if (left && !previousLeft) return -1; if (right && !previousRight) return 1; return 0; } static bool Toggle(bool enabled, bool pressed, bool previous) { return enabled && pressed && !previous; } static bool Casualty(bool active, bool currentBody, bool alive, bool conscious) { return active && currentBody && alive && !conscious; } static bool View(int reason, bool active, bool alive, bool conscious) { if (!active || reason == ERR_ObserverReason.NONE) return false; if (reason == ERR_ObserverReason.UNCONSCIOUS) return alive && !conscious; return !alive; } } // Presentation only. Existing server admission and observer selection own state. class ERR_ObserverGuidance { static string Message(int reason, bool survivorAvailable) { if (reason == ERR_ObserverReason.NONE) return ""; string message = "You are spectating until the next round."; if (reason == ERR_ObserverReason.SPENT_LIFE) message = "Your life for this round has been used. You can play again next round."; else if (reason == ERR_ObserverReason.LATE_EXTRACTION) message = "Extraction has started. New prisoners can join next round."; else if (reason == ERR_ObserverReason.SPAWN_UNAVAILABLE) message = "Your prisoner could not be spawned. You are spectating until the next round."; else if (reason == ERR_ObserverReason.UNCONSCIOUS) { message = "You are unconscious. Your body remains where you fell and teammates can still treat you. Your view returns automatically when you wake up."; if (!survivorAvailable) return message + " No conscious teammate is currently available; the camera stays near your body."; } if (!survivorAvailable) return message + " No teammate or body is currently available to follow."; return message + " Press Left / Right arrow to change teammate or body (or /esc spectate next / /esc spectate previous). Press Enter to switch between third-person and the living teammate's camera. Player view waits in third-person if no live camera stream is available. Use camera look controls to orbit and zoom controls to change distance in third-person."; } static bool Refresh(bool shown, int previousReason, int reason, bool previousAvailable, bool survivorAvailable) { return reason != ERR_ObserverReason.NONE && (!shown || previousReason != reason || previousAvailable != survivorAvailable); } } class ERR_ObserverCameraClass : SCR_ManualCameraClass {} // Gate before SCR_ManualCamera activates its context: disabling only the follow // component is too late, since the camera context can already consume chat keys. class ERR_ObserverCamera : SCR_ManualCamera { override protected bool IsDisabledByMenu() { if (super.IsDisabledByMenu()) return true; if (GetGame().GetInputManager().IsContextActive("ChatContext")) return true; Widget focused = GetGame().GetWorkspace().GetFocusedWidget(); while (focused) { if (EditBoxWidget.Cast(focused)) return true; focused = focused.GetParent(); } return false; } } // A local follow camera. It never possesses its target or offers free flight. [BaseContainerProps(), SCR_BaseManualCameraComponentTitle()] class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent { protected IEntity m_Target; protected IEntity m_Vehicle; protected float m_Yaw; protected float m_Pitch = 20; protected float m_Distance = 5; protected bool m_LeftDown, m_RightDown, m_EnterDown; protected int m_InputResumeFrames; protected bool m_PlayerView; protected ref ERR_ObserverHeadVisibility m_ViewHead = new ERR_ObserverHeadVisibility(); protected float m_OrbitFov, m_OrbitNear; protected ref ERR_ObserverScopeDisplay m_ScopeDisplay = new ERR_ObserverScopeDisplay(); protected ref ERR_ObserverPIPDisplay m_PIPDisplay = new ERR_ObserverPIPDisplay(); protected bool m_InputContextActive; bool InputContextActive() { return m_InputContextActive; } bool IsPlayerView() { return m_PlayerView; } // Process one edge per press; menus still consume the state so holding a key // while closing a menu cannot unexpectedly change the followed teammate. void Input(bool enabled, bool left, bool right, bool enter) { // An inactive context reports zero even while a physical key is held. Let // the reactivated context settle, then require release before accepting keys. if (!enabled) m_InputResumeFrames = 2; else if (m_InputResumeFrames > 0) { enabled = false; if (m_InputResumeFrames > 1 || (!left && !right && !enter)) m_InputResumeFrames--; } SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); int cycle = ERR_ObserverRules.Cycle(enabled, left, right, m_LeftDown, m_RightDown); if (controller && cycle != 0) controller.ERR_CycleObserver(cycle); if (ERR_ObserverRules.Toggle(enabled, enter, m_EnterDown)) m_PlayerView = !m_PlayerView; m_LeftDown = left; m_RightDown = right; m_EnterDown = enter; } override bool EOnCameraInit() { m_OrbitFov = GetCameraEntity().GetVerticalFOV(); m_OrbitNear = GetCameraEntity().GetNearPlane(); return true; } override void EOnCameraExit() { m_PIPDisplay.Close(); m_ScopeDisplay.Close(); m_ViewHead.Restore(); } override void EOnCameraFrame(SCR_ManualCameraParam param) { SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController()); if (!controller || GetGame().GetCameraManager().CurrentCamera() != GetCameraEntity()) { m_PIPDisplay.Close(); m_ScopeDisplay.Close(); m_ViewHead.Restore(); return; } m_InputContextActive = GetInputManager().IsContextActive("ERR_ObserverContext"); Input(param.isManualInputEnabled, GetInputManager().GetActionValue("ERR_ObserverPrevious") > 0.5, GetInputManager().GetActionValue("ERR_ObserverNext") > 0.5, GetInputManager().GetActionValue("ERR_ObserverView") > 0.5); IEntity target = controller.ERR_ObservedEntity(); float fov, nearPlane; if (m_PlayerView && controller.ERR_ReadPlayerView(target, param.timeSlice, param.transform, fov, nearPlane)) { param.fov = fov; GetCameraEntity().SetNearPlane(nearPlane); RplId scopeWeapon; vector scopeData; controller.ERR_ReadScope(scopeWeapon, scopeData); SCR_2DOpticsComponent optic = ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData); IEntity weapon; if (optic) { RplComponent weaponRpl = RplComponent.Cast(Replication.FindItem(scopeWeapon)); if (weaponRpl) weapon = weaponRpl.GetEntity(); } vector eye[4], geometry; float eyeFov, eyeNear, pipFov, vignette; // Read both timelines every camera frame, including the 2D fallback, so // enabling PiP cannot resume an eye camera from a different time. bool haveEye = controller.ERR_ReadPlayerEye(target, param.timeSlice, eye, eyeFov, eyeNear, pipFov, geometry, vignette); bool pip = haveEye && param.isManualInputEnabled && optic && weapon; if (pip) pip = m_PIPDisplay.Render(optic, param.transform, eye, fov, pipFov, scopeData, geometry, vignette); if (pip) { m_ScopeDisplay.Close(); Math3D.MatrixCopy(eye, param.transform); param.fov = eyeFov; GetCameraEntity().SetNearPlane(eyeNear); } else { m_PIPDisplay.Close(); if (!param.isManualInputEnabled || !optic || !weapon || !m_ScopeDisplay.RenderScope(optic, weapon, fov, scopeData)) m_ScopeDisplay.Close(); } m_ViewHead.Update(target, param.transform[3]); param.isDirty = true; return; } // No estimated head camera: corpses, streaming gaps and expired samples use // the stable follow view until an authenticated live camera stream arrives. m_PIPDisplay.Close(); m_ScopeDisplay.Close(); m_ViewHead.Restore(); param.fov = m_OrbitFov; GetCameraEntity().SetNearPlane(m_OrbitNear); vector aim = controller.ERR_ObserverAnchor() + "0 1.5 0"; vector position = aim + "0 4 -6"; IEntity exclude = target; if (target) { IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(target); vector transform[4]; if (vehicle) { vehicle.GetWorldTransform(transform); vector lower, upper; vehicle.GetWorldBounds(lower, upper); aim = (lower + upper) * 0.5; exclude = vehicle; } else { target.GetWorldTransform(transform); aim = transform[3] + "0 1.5 0"; if (!ERR_Actors.Alive(target)) aim = transform[3] + "0 0.4 0"; } if (target != m_Target || vehicle != m_Vehicle) { vector angles = transform[2].VectorToAngles(); m_Yaw = angles[0] + 180; m_Pitch = 20; m_Distance = 5; if (vehicle) m_Distance = 12; m_Target = target; m_Vehicle = vehicle; } if ((!m_PlayerView || !ERR_Actors.Alive(target)) && param.isManualInputEnabled) { m_Yaw += GetInputManager().GetActionValue("ManualCameraRotateYaw") * 90 * param.timeSlice; m_Pitch = Math.Clamp(m_Pitch - GetInputManager().GetActionValue("ManualCameraRotatePitch") * 90 * param.timeSlice, 5, 75); float scale = Math.Clamp(1 + GetInputManager().GetActionValue("ManualCameraZoom") * 12 * param.timeSlice, 0.5, 1.5); float minimum = 2, maximum = 12; if (vehicle) { minimum = 6; maximum = 25; } m_Distance = Math.Clamp(m_Distance * scale, minimum, maximum); } float yaw = m_Yaw * Math.DEG2RAD, pitch = m_Pitch * Math.DEG2RAD; vector offset = Vector(Math.Sin(yaw) * Math.Cos(pitch), Math.Sin(pitch), Math.Cos(yaw) * Math.Cos(pitch)); position = aim + offset * m_Distance; } TraceParam trace = new TraceParam(); trace.Start = aim; trace.End = position; trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS; trace.LayerMask = TRACE_LAYER_CAMERA; trace.Exclude = exclude; float fraction = GetCameraEntity().GetWorld().TraceMove(trace, null); if (fraction < 1) position = vector.Lerp(aim, position, Math.Max(0.05, fraction - 0.05)); Math3D.DirectionAndUpMatrix(vector.Direction(position, aim), "0 1 0", param.transform); param.transform[3] = position; param.isDirty = true; } } modded class SCR_PlayerController { [RplProp()] protected ERR_ObserverReason m_ERRObserverReason; [RplProp()] protected RplId m_ERRObservedId = RplId.Invalid(); [RplProp()] protected vector m_ERRObserverAnchor; protected IEntity m_ERRServerObserved; protected IEntity m_ERRServerObservedVehicle; protected SCR_ManualCamera m_ERRObserverCamera; protected bool m_ERRObserverMonitor; protected bool m_ERRObserverReady; protected ERR_ObserverReason m_ERRShownObserverReason; protected bool m_ERRShownObserverAvailable; protected string m_ERRShownRecoveryStatus; protected float m_ERRLastCycle = -1000; protected IEntity m_ERRRecoveryBody; protected float m_ERRRecoveryDeadline = -1; ERR_ObserverReason ERR_ObserverState() { return m_ERRObserverReason; } vector ERR_ObserverAnchor() { return m_ERRObserverAnchor; } IEntity ERR_ObservedEntity() { RplComponent component = RplComponent.Cast(Replication.FindItem(m_ERRObservedId)); if (!component) return null; return component.GetEntity(); } bool ERR_HasObserverCamera() { return m_ERRObserverCamera != null; } void ERR_SetObserver(ERR_ObserverReason reason) { if (!Replication.IsServer()) return; if (reason != ERR_ObserverReason.NONE && ERR_Actors.Alive(GetControlledEntity()) && reason != ERR_ObserverReason.UNCONSCIOUS) return; m_ERRObserverReason = reason; ERR_GameMode mode = ERR_GameMode.Get(); if (mode && reason != ERR_ObserverReason.NONE) m_ERRObserverAnchor = mode.PrisonPosition(); if (reason == ERR_ObserverReason.NONE) ERR_ReleaseObserved(); Replication.BumpMe(); ERR_UpdateObserverServer(); } protected void ERR_StreamObserved(IEntity entity, bool normalStreaming) { RplIdentity identity = GetRplIdentity(); if (!identity.IsValid() || identity == RplIdentity.Local() || !entity) return; RplComponent component = RplComponent.Cast(entity.FindComponent(RplComponent)); if (component) component.EnableStreamingConNode(identity, normalStreaming); } protected void ERR_ReleaseObserved() { ERR_StreamObserved(m_ERRServerObserved, true); ERR_StreamObserved(m_ERRServerObservedVehicle, true); m_ERRServerObserved = null; m_ERRServerObservedVehicle = null; m_ERRObservedId = RplId.Invalid(); } void ERR_UpdateObserverServer(int direction = 0) { if (!Replication.IsServer()) return; ERR_GameMode mode = ERR_GameMode.Get(); IEntity body = GetControlledEntity(); bool casualty = mode && ERR_ObserverRules.Casualty(mode.Active(), mode.Players() && mode.Players().CurrentActor(body) != null, ERR_Actors.Alive(body), ERR_Actors.CanFight(body)); if (casualty && m_ERRObserverReason != ERR_ObserverReason.UNCONSCIOUS) { m_ERRObserverReason = ERR_ObserverReason.UNCONSCIOUS; m_ERRObserverAnchor = body.GetOrigin(); } if (m_ERRObserverReason == ERR_ObserverReason.UNCONSCIOUS && !ERR_Actors.Alive(body)) m_ERRObserverReason = ERR_ObserverReason.SPENT_LIFE; // Death can leave the controller attached to its corpse, so entity-lost is // not a reliable trigger (especially with the last/only player). ERR_PlayerLife life; if (mode && mode.Players()) life = mode.Players().Get(GetPlayerId()); if (mode && mode.Active() && life && life.UsedLife && life.Actor && !ERR_Actors.Alive(life.Actor) && !ERR_Actors.Alive(body) && m_ERRObserverReason == ERR_ObserverReason.NONE) { m_ERRObserverReason = ERR_ObserverReason.SPENT_LIFE; m_ERRObserverAnchor = life.Actor.GetOrigin(); } if (m_ERRObserverReason == ERR_ObserverReason.NONE) return; if (!mode || !mode.Active() || (ERR_Actors.Alive(body) && !casualty) || (m_ERRObserverReason == ERR_ObserverReason.UNCONSCIOUS && !casualty)) { m_ERRObserverReason = ERR_ObserverReason.NONE; ERR_ReleaseObserved(); Replication.BumpMe(); return; } array survivors = {}; ERR_Actors.Players(survivors); Faction friendly = mode.PlayerFaction(); for (int i = survivors.Count() - 1; i >= 0; i--) if (survivors[i] == body || (casualty && !ERR_Actors.CanFight(survivors[i])) || !friendly || !mode.Players() || !mode.Players().CurrentActor(survivors[i]) || SCR_FactionManager.SGetFaction(survivors[i]) != friendly) survivors.RemoveOrdered(i); // Dead teammates remain selectable alongside survivors, including our own body. ERR_ObserverTargets.Bodies(mode, survivors); int index = survivors.Find(m_ERRServerObserved); if (index < 0) { if (life && life.Actor) index = survivors.Find(life.Actor); if (index < 0) index = 0; } else index += direction; if (index < 0) index = survivors.Count() - 1; if (index >= survivors.Count()) index = 0; IEntity target; if (!survivors.IsEmpty()) target = survivors[index]; if (target != m_ERRServerObserved) { ERR_ReleaseObserved(); m_ERRServerObserved = target; if (target) { RplComponent component = RplComponent.Cast(target.FindComponent(RplComponent)); if (component) m_ERRObservedId = component.Id(); ERR_StreamObserved(target, false); } } if (target) { m_ERRObserverAnchor = target.GetOrigin(); IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(target); if (vehicle != m_ERRServerObservedVehicle) { ERR_StreamObserved(m_ERRServerObservedVehicle, true); m_ERRServerObservedVehicle = vehicle; ERR_StreamObserved(vehicle, false); } } else if (casualty) m_ERRObserverAnchor = body.GetOrigin(); Replication.BumpMe(); } void ERR_CycleObserver(int direction) { if (this == GetGame().GetPlayerController()) Rpc(ERR_RequestObserverCycle, direction); } [RplRpc(RplChannel.Reliable, RplRcver.Server)] protected void ERR_RequestObserverCycle(int direction) { if (!Replication.IsServer() || (direction != -1 && direction != 1)) return; float now = GetGame().GetWorld().GetWorldTime(); if (now - m_ERRLastCycle < 250) return; m_ERRLastCycle = now; ERR_UpdateObserverServer(direction); } void ERR_StartObserverMonitor() { if (this != GetGame().GetPlayerController() || System.IsConsoleApp() || m_ERRObserverMonitor) return; m_ERRObserverMonitor = true; ERR_OnObserverMonitorStarted(); GetGame().GetCallqueue().CallLater(ERR_UpdateObserverClient, 250, true); } protected void ERR_OnObserverMonitorStarted() {} protected void ERR_UpdateObserverClient() { if (!GetGame()) return; if (this != GetGame().GetPlayerController()) { // A replaced controller may retain its timer until native destruction. // Release only its presentation; never restore its old body camera. GetGame().GetCallqueue().Remove(ERR_UpdateObserverClient); m_ERRObserverMonitor = false; m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; ERR_CloseObserverCamera(); return; } ERR_GameMode mode = ERR_GameMode.Get(); ERR_UpdateRecoveryCamera(mode); bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity())); if (!observe) { ERR_CloseObserverCamera(); return; } SCR_EditorManagerEntity editor = SCR_EditorManagerEntity.GetInstance(); if (editor && editor.IsOpened()) return; if (!m_ERRObserverCamera) { Resource resource = Resource.Load("{EB1210AAE1BB8F54}Prefabs/ERR/SpectatorCamera.et"); if (!resource || !resource.IsValid()) return; EntitySpawnParams params = new EntitySpawnParams(); params.TransformMode = ETransformMode.WORLD; params.Transform[3] = m_ERRObserverAnchor + "0 5 -6"; m_ERRObserverCamera = SCR_ManualCamera.Cast(GetGame().SpawnEntityPrefabLocal(resource, GetGame().GetWorld(), params)); if (!m_ERRObserverCamera) return; } CameraManager manager = GetGame().GetCameraManager(); if (!manager) return; // Native death/respawn camera changes can arrive after our camera is created. if (manager.CurrentCamera() != m_ERRObserverCamera) manager.SetCamera(m_ERRObserverCamera); if (manager.CurrentCamera() != m_ERRObserverCamera) return; SCR_RespawnSystemComponent respawn = SCR_RespawnSystemComponent.GetInstance(); if (respawn) respawn.DestroyLoadingPlaceholder(); // The server-selected replicated ID distinguishes no target from a target // whose local entity is still streaming. Never imply no survivors from delay. bool survivorAvailable = m_ERRObservedId != RplId.Invalid(); string recovery = mode.RecoveryStatus(); if (ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable) || m_ERRShownRecoveryStatus != recovery) { SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance(); if (!hints) return; // retry on the existing monitor; do not lose the first hint string message = ERR_ObserverGuidance.Message(m_ERRObserverReason, survivorAvailable); if (!recovery.IsEmpty()) message += "\n" + recovery; if (!hints.ShowCustom(message, "Spectating", 12)) return; m_ERRObserverReady = true; m_ERRShownObserverReason = m_ERRObserverReason; m_ERRShownObserverAvailable = survivorAvailable; m_ERRShownRecoveryStatus = recovery; } } // Track the actual local body even when an editor/other camera owns the view. // Run once per recovery, with retries for asynchronous editor close/camera setup. protected void ERR_UpdateRecoveryCamera(ERR_GameMode mode) { IEntity body = GetControlledEntity(); if (!mode || !mode.Active() || !ERR_Actors.Alive(body)) { m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; return; } if (!ERR_Actors.CanFight(body)) { m_ERRRecoveryBody = body; m_ERRRecoveryDeadline = -1; return; } if (!m_ERRRecoveryBody) return; if (body != m_ERRRecoveryBody) { m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; return; } float now = GetGame().GetWorld().GetWorldTime(); if (m_ERRRecoveryDeadline < 0) m_ERRRecoveryDeadline = now + 10000; SCR_EditorManagerEntity editor = SCR_EditorManagerEntity.GetInstance(); if (editor && editor.IsOpened()) editor.Close(false); CameraManager manager = GetGame().GetCameraManager(); CameraBase playerCamera = GetPlayerCamera(); if (manager && playerCamera) manager.SetCamera(playerCamera); if ((manager && playerCamera && manager.CurrentCamera() == playerCamera && (!editor || !editor.IsOpened())) || now >= m_ERRRecoveryDeadline) { if (now >= m_ERRRecoveryDeadline) Print("Reintroduced [spectator]: recovery camera return timed out waiting for editor/camera; player body remains controlled", LogLevel.WARNING); m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; } } protected void ERR_CloseObserverCamera() { m_ERRObserverReady = false; m_ERRShownRecoveryStatus = ""; if (!m_ERRObserverCamera) return; if (GetGame().GetPlayerController() == this) m_ERRObserverCamera.TrySwitchToControlledEntityCamera(); m_ERRObserverCamera.Terminate(); delete m_ERRObserverCamera; m_ERRObserverCamera = null; } void ~SCR_PlayerController() { if (GetGame()) { GetGame().GetCallqueue().Remove(ERR_UpdateObserverClient); ERR_CloseObserverCamera(); if (Replication.IsServer()) ERR_ReleaseObserved(); } } }