Player/Player.Camera.cs

Player camera and visibility logic for a networked player pawn. Manages the first-person CameraComponent (position, rotation, FOV, near/far), handles special camera states (spectating, caught by monster, death/ragdoll), hides the local body in first person, and applies a spectator outline glow for other players.

Native Interop
using Sandbox;

namespace BrickJam;

public sealed partial class Player
{
	/// <summary>
	/// First-person camera. Lives on a child GameObject of the player prefab and is only
	/// enabled for the owning (non-proxy) client. Replaces the legacy <c>FrameSimulate</c>
	/// camera code which manipulated the global <c>Camera</c>.
	/// </summary>
	[Property] public CameraComponent Camera { get; set; }

	protected override void OnStart()
	{
		SetLocal();

		// Footstep anim events come from the body's animgraph; route them to our handler.
		if ( Body.IsValid() )
			Body.OnFootstepEvent = OnFootstepEvent;

		// Only the owner drives/sees through their own camera.
		if ( Camera.IsValid() )
			Camera.GameObject.Enabled = !IsProxy;

		// The owner loads its stored save and pushes it to the host.
		if ( !IsProxy )
			SendStoredSaveToServer();
	}

	private void UpdateCamera()
	{
		if ( !Camera.IsValid() )
			return;

		// While spectating (dead / late joiner) the connection's spectator pawn owns the view, so turn
		// our own camera off and let it take over.
		if ( Spectating )
		{
			Camera.Enabled = false;
			return;
		}

		if ( !Camera.Enabled )
			Camera.Enabled = true;

		if ( CameraTarget.IsValid() )
		{
			// Caught: turn the camera to face the monster that grabbed us, looking at its head bone (legacy
			// CameraTarget death cam). Falls back to the monster's head height if its model has no "head" bone.
			var headPos = CameraTarget.Body.IsValid() && CameraTarget.Body.TryGetBoneTransform( "head", out var headTx )
				? headTx.Position
				: CameraTarget.WorldPosition + Vector3.Up * CameraTarget.CollisionHeight;

			var lookRot = Rotation.LookAt( headPos - Camera.WorldPosition );

			// Camera stays at our own eyes (same "eyes" attachment EyePosition uses), nudged back/down along
			// the look direction so the near plane stays off our face (legacy parity).
			var eyes = Body.IsValid() && Body.GetAttachment( "eyes" ) is { } a
				? a.Position
				: WorldPosition + Vector3.Up * (IsCrouching ? 28f : 64f);

			Camera.WorldPosition = eyes - lookRot.Up * 2f - lookRot.Forward * 3f;
			Camera.WorldRotation = lookRot;
		}
		else if ( !MovementLocked )
		{
			Camera.WorldRotation = InputRotation;
			Camera.WorldPosition = EyePosition;
		}
		else
		{
			// Death/ragdoll cam - pull back.
			var target = WorldPosition - Velocity.WithZ( 0 ).Normal * 30f + Vector3.Up * 50f;
			Camera.WorldRotation = Rotation.LookAt( WorldPosition + Vector3.Up * 16f - Camera.WorldPosition );
			Camera.WorldPosition = Vector3.Lerp( Camera.WorldPosition, target, Time.Delta * 10f );
		}

		Camera.ZNear = 2f;
		Camera.ZFar = 4096f;
		Camera.FieldOfView = Screen.CreateVerticalFieldOfView( 60f );
	}

	/// <summary>
	/// Runs for EVERY pawn on EVERY client (see Player.OnUpdate): hide OUR OWN body in first person, and force
	/// every other player's body visible. The force-visible is essential - the host's body RenderType is
	/// replicated to clients on spawn, so a host who set ShadowsOnly locally would otherwise stay invisible to
	/// clients; setting proxies to On every frame corrects that locally on each client.
	/// </summary>
	private void UpdateBodyVisibility()
	{
		// Hide our own body in first person (alive). ALSO hide a player we're currently SPECTATING in first
		// person - the local spectator views out through their eyes, so their body+mask shouldn't be in shot.
		// Everyone else stays visible (force-On overrides the spawn-replicated host RenderType, see above).
		var spectatedByLocal = Spectator.Local.IsValid() && Spectator.Local.Following == this;
		SetOwnBodyHidden( (!IsProxy && !MovementLocked) || spectatedByLocal );
	}

	/// <summary>
	/// Set our body+mask render type. <c>RenderType</c> is non-synced, so this only affects what THIS client
	/// renders. <c>ShadowsOnly</c> hides the model but keeps the shadow.
	/// </summary>
	private void SetOwnBodyHidden( bool hidden )
	{
		var renderType = hidden ? ModelRenderer.ShadowRenderType.ShadowsOnly : ModelRenderer.ShadowRenderType.On;

		if ( Body.IsValid() )
			Body.RenderType = renderType;

		if ( Mask.IsValid() )
			Mask.RenderType = renderType;
	}

	private HighlightOutline spectatorGlow;

	/// <summary>
	/// Runs on every client (see Player.OnUpdate): when the LOCAL client is spectating (has a Spectator pawn)
	/// and isn't currently watching this player in first person, outline this living player in their slot
	/// colour - visible through walls - so spectators can find everyone. Scene-System port of the legacy
	/// per-frame <c>Glow</c> loop in MansionGame.
	/// </summary>
	private void UpdateSpectatorGlow()
	{
		var local = Spectator.Local;
		var show = IsAlive && local.IsValid() && local.Following != this;

		if ( !show )
		{
			if ( spectatorGlow.IsValid() )
				spectatorGlow.Enabled = false;
			return;
		}

		spectatorGlow ??= Components.GetOrCreate<HighlightOutline>();
		spectatorGlow.Enabled = true;
		spectatorGlow.Color = SlotColor;
		spectatorGlow.ObscuredColor = SlotColor.WithAlpha( 0.6f ); // shown through walls
		spectatorGlow.Width = 0.5f;
	}
}