Capture current mission, AI, map and administration development

Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
2026-10-13 22:52:53 +02:00
parent a951a96edc
commit 6a667d8deb
8104 changed files with 2124945 additions and 2061 deletions
@@ -0,0 +1,30 @@
ActionManager : "{795184CF9AD764DB}Configs/System/chimeraInputCommon.conf" {
Contexts {
ActionContext ERR_ObserverContext : "{C538807EEE48BED8}Configs/System/ActionContext/ManualCameraContext.conf" {
Actions {
Action ERR_ObserverPrevious {
InputSource InputSourceValue "{A7B19F416E541001}" {
Input "keyboard:KC_LEFT"
}
}
Action ERR_ObserverNext {
InputSource InputSourceValue "{A7B19F416E541002}" {
Input "keyboard:KC_RIGHT"
}
}
Action ERR_ObserverView {
InputSource InputSourceSum "{A7B19F416E541003}" {
Sources {
InputSourceValue "{A7B19F416E541004}" {
Input "keyboard:KC_RETURN"
}
InputSourceValue "{A7B19F416E541005}" {
Input "keyboard:KC_NUMPADENTER"
}
}
}
}
}
}
}
}
@@ -0,0 +1,11 @@
MetaFileClass {
Name "{A7B19F416E541000}Configs/ERR/ObserverInput.conf"
Configurations {
CONFResourceClass PC { }
CONFResourceClass HEADLESS : PC { }
CONFResourceClass XBOX_ONE : PC { }
CONFResourceClass XBOX_SERIES : PC { }
CONFResourceClass PS4 : PC { }
CONFResourceClass PS5 : PC { }
}
}
@@ -0,0 +1,9 @@
ERR_ObserverCamera {
ID "634904BCEDA1A285"
m_bInputEnabled 1
m_sActionContext "ERR_ObserverContext"
m_bStartAtEntity 1
m_aComponents {
ERR_FollowSurvivorCameraComponent "{965C2E8B2339B926}" {}
}
}
@@ -0,0 +1,9 @@
MetaFileClass {
Name "{EB1210AAE1BB8F54}Prefabs/ERR/SpectatorCamera.et"
Configurations {
EntityTemplateResourceClass PC {
}
EntityTemplateResourceClass HEADLESS : PC {
}
}
}
@@ -0,0 +1,463 @@
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 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_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_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);
m_ViewHead.Update(target, param.transform[3]);
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();
}
if (!param.isManualInputEnabled || !optic || !weapon || !m_ScopeDisplay.RenderScope(optic, weapon, fov, scopeData)) m_ScopeDisplay.Close();
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_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<IEntity> 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();
}
}
}
@@ -0,0 +1,218 @@
// Camera poses are cosmetic, owner-sampled and relayed only to authorized
// spectators of that body. No client-supplied position affects gameplay.
class ERR_ObserverViewRules
{
static bool Sample(vector position, vector forward, vector up, float fov, float nearPlane)
{
// Negated bounded comparisons also reject NaN/infinity before matrix math.
if (!(position.LengthSq() <= 900) || !(fov >= 1 && fov <= 150) || !(nearPlane >= 0.005 && nearPlane <= 2)) return false;
if (!(forward.LengthSq() >= 0.9 && forward.LengthSq() <= 1.1) || !(up.LengthSq() >= 0.9 && up.LengthSq() <= 1.1)) return false;
return Math.AbsFloat(vector.Dot(forward, up)) <= 0.05;
}
static bool Fresh(float now, float received) { return received >= 0 && now >= received && now - received <= 350; }
}
class ERR_ObserverViewBuffer
{
protected RplId m_Target = RplId.Invalid();
protected int m_Stamp = -1;
protected RplId m_ScopeWeapon = RplId.Invalid();
protected vector m_ScopeData;
void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
protected float m_Received = -1;
protected vector m_Position, m_Forward, m_Up;
protected vector m_StartPosition, m_StartForward, m_StartUp;
protected float m_Fov, m_Near, m_StartFov, m_Blend = 1;
void Clear() { m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1; }
void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
{
if (!ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
if (!ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
if (target == m_Target && stamp <= m_Stamp) return;
if (target != m_Target || scopeWeapon != m_ScopeWeapon || scopeData[0] != m_ScopeData[0] || !ERR_ObserverViewRules.Fresh(now, m_Received))
{ m_StartPosition = position; m_StartForward = forward; m_StartUp = up; m_StartFov = fov; }
else
{
m_StartPosition = vector.Lerp(m_StartPosition, m_Position, m_Blend);
m_StartForward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
m_StartUp = vector.Lerp(m_StartUp, m_Up, m_Blend);
m_StartFov = Math.Lerp(m_StartFov, m_Fov, m_Blend);
}
m_ScopeWeapon = scopeWeapon; m_ScopeData = scopeData;
m_Target = target; m_Stamp = stamp; m_Received = now; m_Blend = 0;
m_Position = position; m_Forward = forward; m_Up = up; m_Fov = fov; m_Near = nearPlane;
}
bool Read(RplId target, float now, float seconds, out vector local[4], out float fov, out float nearPlane)
{
if (target != m_Target || !ERR_ObserverViewRules.Fresh(now, m_Received)) return false;
m_Blend = Math.Clamp(m_Blend + Math.Max(0, seconds) / 0.05, 0, 1);
vector forward = vector.Lerp(m_StartForward, m_Forward, m_Blend);
vector up = vector.Lerp(m_StartUp, m_Up, m_Blend);
if (forward.LengthSq() < 0.1 || up.LengthSq() < 0.1) { forward = m_Forward; up = m_Up; }
forward.Normalize(); up.Normalize();
if (Math.AbsFloat(vector.Dot(forward, up)) > 0.95) { forward = m_Forward; up = m_Up; }
Math3D.DirectionAndUpMatrix(forward, up, local);
local[3] = vector.Lerp(m_StartPosition, m_Position, m_Blend);
fov = Math.Lerp(m_StartFov, m_Fov, m_Blend); nearPlane = m_Near;
return true;
}
}
modded class SCR_PlayerController
{
protected ref ERR_ObserverViewBuffer m_ERRViewBuffer = new ERR_ObserverViewBuffer();
protected ref array<SCR_PlayerController> m_ERRViewWatchers = {};
protected float m_ERRViewServerRequestAt = -1000;
protected float m_ERRViewRequestAt = -1000, m_ERRViewLeaseUntil = -1;
protected float m_ERRViewPublishUntil = -1, m_ERRViewLeaseSent = -1000;
protected float m_ERRViewSendWait, m_ERRViewLastPacket = -1000;
protected int m_ERRViewSequence, m_ERRViewInputSequence;
protected IEntity m_ERRViewSourceBody;
bool ERR_ViewRecipient(IEntity target, float now)
{
ERR_GameMode mode = ERR_GameMode.Get();
return mode && mode.Active() && now <= m_ERRViewLeaseUntil && target && target == m_ERRServerObserved && ERR_Actors.CanFight(target) && mode.Players().CurrentActor(target) && SCR_FactionManager.SGetFaction(target) == mode.PlayerFaction() && GetGame().GetPlayerManager().GetPlayerController(GetPlayerId()) == this && ERR_ObserverRules.View(m_ERRObserverReason, true, ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
}
void ERR_ReadScope(out RplId weapon, out vector data) { m_ERRViewBuffer.Scope(weapon, data); }
bool ERR_ReadPlayerView(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane)
{
if (this != GetGame().GetPlayerController() || !target || !ERR_Actors.CanFight(target)) return false;
float now = GetGame().GetWorld().GetWorldTime();
if (now - m_ERRViewRequestAt >= 500)
{ m_ERRViewRequestAt = now; Rpc(ERR_RequestPlayerView); }
vector local[4], world[4];
if (!m_ERRViewBuffer.Read(m_ERRObservedId, now, seconds, local, fov, nearPlane)) return false;
RplId scopeWeapon; vector scopeData;
m_ERRViewBuffer.Scope(scopeWeapon, scopeData);
if (scopeWeapon != RplId.Invalid() && !ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData)) return false;
target.GetWorldTransform(world);
Math3D.MatrixMultiply4(world, local, transform);
return true;
}
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
protected void ERR_RequestPlayerView()
{
if (!Replication.IsServer()) return;
float now = GetGame().GetWorld().GetWorldTime();
if (now - m_ERRViewServerRequestAt < 250) return;
m_ERRViewServerRequestAt = now;
// A lease grants no observer rights; all existing server selection/roster
// checks still apply, both here and when each sample is forwarded.
m_ERRViewLeaseUntil = now + 1500;
if (!ERR_ViewRecipient(m_ERRServerObserved, now)) return;
ERR_PlayerLife life = ERR_GameMode.Get().Players().CurrentActor(m_ERRServerObserved);
SCR_PlayerController source = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId));
if (source) source.ERR_AddViewWatcher(this, now);
}
void ERR_AddViewWatcher(SCR_PlayerController watcher, float now)
{
if (!Replication.IsServer() || !watcher) return;
for (int i = m_ERRViewWatchers.Count() - 1; i >= 0; i--)
if (!m_ERRViewWatchers[i] || !m_ERRViewWatchers[i].ERR_ViewRecipient(GetControlledEntity(), now)) m_ERRViewWatchers.Remove(i);
if (!m_ERRViewWatchers.Contains(watcher) && m_ERRViewWatchers.Count() < 64) m_ERRViewWatchers.Insert(watcher);
if (now - m_ERRViewLeaseSent < 250) return;
m_ERRViewLeaseSent = now;
Rpc(ERR_EnableViewPublisher);
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_EnableViewPublisher()
{
if (this == GetGame().GetPlayerController()) m_ERRViewPublishUntil = GetGame().GetWorld().GetWorldTime() + 2000;
}
void ERR_PublishPlayerView(float seconds)
{
if (this != GetGame().GetPlayerController() || System.IsConsoleApp()) return;
float now = GetGame().GetWorld().GetWorldTime();
if (now > m_ERRViewPublishUntil) return;
m_ERRViewSendWait += seconds;
if (m_ERRViewSendWait < 0.05) return;
m_ERRViewSendWait = 0;
ERR_GameMode mode = ERR_GameMode.Get();
IEntity actor = GetControlledEntity();
PlayerCamera camera = GetPlayerCamera();
CameraManager manager = GetGame().GetCameraManager();
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor) || !camera || !manager || manager.CurrentCamera() != camera) return;
vector world[4], view[4], local[4];
actor.GetWorldTransform(world);
camera.GetWorldCameraTransform(view);
float fov = camera.GetVerticalFOV(), nearPlane = camera.GetNearPlane();
RplId scopeWeapon; vector scopeData;
ERR_ObserverScope.Sample(actor, view, fov, scopeWeapon, scopeData);
Math3D.MatrixInvMultiply4(world, view, local);
if (!ERR_ObserverViewRules.Sample(local[3], local[2], local[1], fov, nearPlane)) return;
Rpc(ERR_SubmitPlayerView, ++m_ERRViewSequence, local[3], local[2], local[1], fov, nearPlane, scopeWeapon, scopeData);
}
[RplRpc(RplChannel.Unreliable, RplRcver.Server)]
protected void ERR_SubmitPlayerView(int sequence, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
{
if (!Replication.IsServer() || !ERR_ObserverScope.Valid(scopeWeapon, scopeData) || !ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
ERR_GameMode mode = ERR_GameMode.Get();
IEntity actor = GetControlledEntity();
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor)) return;
ERR_PlayerLife life = mode.Players().CurrentActor(actor);
if (!life || GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId) != this) return;
float now = GetGame().GetWorld().GetWorldTime();
if (actor != m_ERRViewSourceBody) { m_ERRViewSourceBody = actor; m_ERRViewInputSequence = 0; }
if (sequence <= m_ERRViewInputSequence || now - m_ERRViewLastPacket < 35) return;
m_ERRViewInputSequence = sequence; m_ERRViewLastPacket = now;
RplComponent component = RplComponent.Cast(actor.FindComponent(RplComponent));
if (!component) return;
for (int i = m_ERRViewWatchers.Count() - 1; i >= 0; i--)
{
SCR_PlayerController watcher = m_ERRViewWatchers[i];
if (!watcher || !watcher.ERR_ViewRecipient(actor, now)) { m_ERRViewWatchers.Remove(i); continue; }
watcher.ERR_DeliverPlayerView(component.Id(), now, position, forward, up, fov, nearPlane, scopeWeapon, scopeData);
}
}
void ERR_DeliverPlayerView(RplId target, int stamp, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
{
Rpc(ERR_ReceivePlayerViewPacket, target, stamp, position, forward, up, Vector(fov, nearPlane, 0), scopeWeapon, scopeData);
}
[RplRpc(RplChannel.Unreliable, RplRcver.Owner)]
protected void ERR_ReceivePlayerViewPacket(RplId target, int stamp, vector position, vector forward, vector up, vector lens, RplId scopeWeapon, vector scopeData)
{
ERR_ReceivePlayerView(target, stamp, position, forward, up, lens[0], lens[1], scopeWeapon, scopeData);
}
protected void ERR_ReceivePlayerView(RplId target, int stamp, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (this != GetGame().GetPlayerController() || target == RplId.Invalid() || target != m_ERRObservedId || !mode || !ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()))) return;
m_ERRViewBuffer.Push(target, stamp, GetGame().GetWorld().GetWorldTime(), position, forward, up, fov, nearPlane, scopeWeapon, scopeData);
}
}
modded class ArmaReforgerScripted
{
override void OnUpdate(BaseWorld world, float timeslice)
{
super.OnUpdate(world, timeslice);
SCR_PlayerController controller = SCR_PlayerController.Cast(GetPlayerController());
if (controller) controller.ERR_PublishPlayerView(timeslice);
}
}
// Use the game's own head/helmet fade rather than moving the camera ahead of
// the face. Changes are local and reversed when the observer stops using them.
class ERR_ObserverHeadVisibility
{
protected SCR_ChimeraCharacter m_Character;
protected SCR_CharacterCameraHandlerComponent m_Handler;
protected int m_Previous, m_Applied;
void Update(IEntity target, vector cameraPosition)
{
if (target != m_Character)
{
Restore();
m_Character = SCR_ChimeraCharacter.Cast(target);
if (!m_Character || !m_Character.GetCharacterController()) return;
m_Handler = SCR_CharacterCameraHandlerComponent.Cast(m_Character.GetCharacterController().GetCameraHandlerComponent());
m_Previous = m_Character.m_fFaceAlphaTest;
}
if (!m_Handler || !m_Character.GetAnimation()) return;
m_Handler.UpdateHeadVisibility(cameraPosition);
m_Applied = m_Character.m_fFaceAlphaTest;
}
void Restore()
{
if (m_Handler && m_Character && m_Character.m_fFaceAlphaTest == m_Applied) m_Handler.OnAlphatestChange(m_Previous);
m_Character = null; m_Handler = null;
}
void ~ERR_ObserverHeadVisibility() { Restore(); }
}
@@ -0,0 +1,134 @@
// Presentation only. Scoped spectators use a private full-screen optic display;
// never activate the remote sight (which registers weapon input and changes HUD).
class ERR_ObserverScope
{
static bool Valid(RplId weapon, vector data)
{
if (weapon == RplId.Invalid()) return data == vector.Zero;
return data[0] >= 1 && data[0] <= 32 && data[0] == Math.Round(data[0]) && data[1] >= -90 && data[1] <= 90 && (data[2] == 0 || data[2] == 1);
}
static void Sample(IEntity actor, out vector view[4], out float fov, out RplId weaponId, out vector data)
{
weaponId = RplId.Invalid(); data = vector.Zero;
ChimeraCharacter character = ChimeraCharacter.Cast(actor);
if (!character) return;
CharacterControllerComponent control = character.GetCharacterController();
if (!control || !control.IsWeaponADS()) return;
BaseWeaponManagerComponent weapons = control.GetWeaponManagerComponent();
if (!weapons || !weapons.GetCurrentWeapon()) return;
SCR_2DOpticsComponent optic = SCR_2DOpticsComponent.Cast(weapons.GetCurrentSights());
if (!optic || !optic.IsSightADSActive()) return;
RplComponent rpl = RplComponent.Cast(weapons.GetCurrentWeapon().GetOwner().FindComponent(RplComponent));
if (!rpl || rpl.Id() == RplId.Invalid()) return;
float scopeFov = optic.GetFOV();
if (!(scopeFov >= 1 && scopeFov <= 150)) return;
vector sample = Vector(weapons.GetCurrentSightsIndex() + 1, optic.GetCurrentReticleOffsetY(), 0);
if (optic.ERR_ObserverIlluminated()) sample[2] = 1;
if (!Valid(rpl.Id(), sample)) return;
// A PiP sight has its own sight-line camera. Use that direction rather than
// magnifying the peripheral eye camera, which need not point down the scope.
SCR_2DPIPSightsComponent pip = SCR_2DPIPSightsComponent.Cast(optic);
if (pip && pip.IsPIPEnabled())
{
if (!pip.GetPIPCamera()) return;
pip.GetPIPCamera().GetWorldCameraTransform(view);
}
weaponId = rpl.Id(); data = sample; fov = scopeFov;
}
static SCR_2DOpticsComponent Resolve(IEntity target, RplId weaponId, vector data)
{
if (weaponId == RplId.Invalid() || !Valid(weaponId, data)) return null;
ChimeraCharacter character = ChimeraCharacter.Cast(target);
if (!character || !character.GetCharacterController()) return null;
BaseWeaponManagerComponent weapons = character.GetCharacterController().GetWeaponManagerComponent();
if (!weapons || !weapons.GetCurrentWeapon() || weapons.GetCurrentSightsIndex() + 1 != data[0]) return null;
RplComponent rpl = RplComponent.Cast(weapons.GetCurrentWeapon().GetOwner().FindComponent(RplComponent));
if (!rpl || rpl.Id() != weaponId) return null;
return SCR_2DOpticsComponent.Cast(weapons.GetCurrentSights());
}
}
modded class SCR_2DOpticsComponent
{
bool ERR_ObserverIlluminated() { return m_bIsIlluminationOn; }
}
// Reuse the native reticle textures, angular sizing and vignette layout, without
// registering with the player's HUD, ADS invokers, weapon input or visibility.
class ERR_ObserverScopeDisplay : SCR_2DOpticsDisplay
{
protected ref Widget m_ERRScopeRoot; // Native HUD normally owns this strong reference.
protected IEntity m_ERRHiddenWeapon, m_ERRHiddenOptic;
protected float m_ERRFov;
bool RenderScope(SCR_2DOpticsComponent optic, IEntity weapon, float fov, vector data)
{
if (!optic || !weapon) { Close(); return false; }
if (!m_wRoot)
{
m_ERRScopeRoot = GetGame().GetWorkspace().CreateWidgets("{4CE66FA8219D33D7}UI/layouts/Sights/Optic_Default.layout");
m_wRoot = m_ERRScopeRoot;
if (!m_wRoot) return false;
FindWidgets();
if (!m_wLayoutRear || !m_wLayoutFront || !m_wImgReticle || !m_wImgCover || !m_wBlur || !m_wRearFillLeft || !m_wRearFillTop || !m_wFrontFillLeft || !m_wFrontFillTop) { Close(); return false; }
m_wRoot.SetFlags(WidgetFlags.IGNORE_CURSOR);
m_wRoot.SetZOrder(1); // Standalone workspace root must be above the game viewport.
}
m_ERRFov = fov;
if (m_Optic != optic)
{
RestoreWeapon();
m_Optic = optic;
GetOpticData();
SetupOpticImage();
// Hide only this locally rendered weapon, never the soldier or inventory.
if ((weapon.GetFlags() & EntityFlags.VISIBLE) != 0)
{ m_ERRHiddenWeapon = weapon; weapon.ClearFlags(EntityFlags.VISIBLE, false); }
IEntity opticOwner = optic.GetOwner();
if (opticOwner != weapon && (opticOwner.GetFlags() & EntityFlags.VISIBLE) != 0)
{ m_ERRHiddenOptic = opticOwner; opticOwner.ClearFlags(EntityFlags.VISIBLE, false); }
}
UpdateCurrentFov();
// Native layout uses fill widgets rather than centered anchors. Recompute
// these for resolution/DPI changes, without adding remote animation sway.
WorkspaceWidget workspace = GetGame().GetWorkspace();
float width, height;
workspace.GetScreenSize(width, height);
width = workspace.DPIUnscale(width); height = workspace.DPIUnscale(height);
UpdateScale(1, 1, 1);
m_wFrontFillLeft.SetWidthOverride(LayerDefaultPosition(width, m_fObjectiveSize));
m_wFrontFillTop.SetHeightOverride(LayerDefaultPosition(height, m_fObjectiveSize));
m_wRearFillLeft.SetWidthOverride(LayerDefaultPosition(width, m_fVignetteSize));
m_wRearFillTop.SetHeightOverride(LayerDefaultPosition(height, m_fVignetteSize));
SCR_2DOpticsComponentClass settings = SCR_2DOpticsComponentClass.Cast(optic.GetComponentData(optic.GetOwner()));
if (settings)
{
if (data[2] == 1 && settings.HasIllumination()) ChangeReticleTint(settings.GetReticleIlluminationColor(), settings.GetReticleIlluminationColor());
else ChangeReticleTint(settings.GetReticleColor(), settings.GetReticleOutlineColor());
}
// The relayed pose is already the aiming camera; don't add another local
// weapon-animation sway (remote proxies can be several frames behind).
SetReticleOffset(optic.GetReticleOffsetX(), data[1]);
m_wImgCover.SetVisible(false); m_wBlur.SetIntensity(0); m_wBlur.SetOpacity(0);
m_wRoot.SetOpacity(1); m_wRoot.SetVisible(true);
return true;
}
override void UpdateCurrentFov(bool forceUpdate = false)
{
if (!forceUpdate && float.AlmostEqual(m_fOpticCurrentFov, m_ERRFov)) return;
m_fOpticCurrentFov = m_ERRFov;
UpdateReticleSize();
}
protected void RestoreWeapon()
{
if (m_ERRHiddenWeapon && !m_ERRHiddenWeapon.IsDeleted()) m_ERRHiddenWeapon.SetFlags(EntityFlags.VISIBLE, false);
if (m_ERRHiddenOptic && !m_ERRHiddenOptic.IsDeleted()) m_ERRHiddenOptic.SetFlags(EntityFlags.VISIBLE, false);
m_ERRHiddenWeapon = null; m_ERRHiddenOptic = null;
}
void Close()
{
RestoreWeapon();
m_Optic = null;
if (m_wRoot) m_wRoot.RemoveFromHierarchy();
m_wRoot = null; m_ERRScopeRoot = null;
}
void ~ERR_ObserverScopeDisplay() { Close(); }
}
@@ -0,0 +1,19 @@
// Corpses lose their player-controller association. The round roster, not the
// current controller list, is the authority for which bodies belong to players.
class ERR_ObserverTargets
{
static void Bodies(ERR_GameMode mode, array<IEntity> targets)
{
if (!mode || !mode.Players() || !mode.PlayerFaction()) return;
array<ref ERR_PlayerLife> lives = {};
mode.Players().Participants(lives);
foreach (ERR_PlayerLife life : lives)
{
if (!life || !life.UsedLife || life.AccountId.IsNull() || !life.Actor || ERR_Actors.Alive(life.Actor)) continue;
// Native death cleanup can drop affiliation as well as controller ownership.
Faction faction = SCR_FactionManager.SGetFaction(life.Actor);
if ((faction && faction != mode.PlayerFaction()) || targets.Contains(life.Actor)) continue;
targets.Insert(life.Actor);
}
}
}