Camera component for a vehicle chase view. Follows the vehicle with a spring-arm behind the flattened heading, allows mouse orbit and mouse-wheel zoom, widens FOV with speed, and hides the cursor when no UI modal claims it.
namespace FieldGuide.VehiclePhysics;
/// <summary>
/// Chase camera: spring-arm follow behind the car's flattened heading, FOV widens with speed.
/// Mouse orbits around the car; after a few seconds without mouse input it eases back to the chase
/// position. Mouse wheel zooms distance.
/// </summary>
public sealed class VehicleCamera : Component
{
/// <summary>
/// Seam for "a UI cursor modal is currently open" (spec 3.1). Drive mode needs a locked cursor
/// or AnalogLook stays at zero, so this camera hides the cursor every frame — UNLESS a consumer's
/// UI has a modal up that owns the cursor. Plug that check in here; null (default) means the kit
/// never yields the cursor (single-window driving with no UI modals). Null-safe: unset reads false.
/// </summary>
public static Func<bool> CursorModalOpen { get; set; }
static bool AnyCursorModalOpen
{
get
{
try
{
return CursorModalOpen?.Invoke() ?? false;
}
catch ( NotImplementedException )
{
// Hotload survivor: this static can hold a lambda authored by a REPLACED assembly
// (s&box hotload copies static state but cannot substitute an orphaned lambda, so
// invoking it throws). Drop the stale delegate and fall back to "no modal"; whoever
// owns the seam (the demo bootstrap, or a consumer) re-wires it at session start.
CursorModalOpen = null;
return false;
}
}
}
public VehicleController Target { get; set; }
[Property] public float Distance { get; set; } = 6.5f; // m
[Property] public float Height { get; set; } = 2.2f; // m
[Property] public float BaseFov { get; set; } = 70f;
[Property] public float OrbitReturnDelay { get; set; } = 5f;
[Property] public float MinDistance { get; set; } = 3.0f; // m — wheel zoom in
[Property] public float MaxDistance { get; set; } = 14.0f; // m — wheel zoom out
[Property] public float ZoomPerNotch { get; set; } = 0.85f; // m per wheel tick
CameraComponent _camera;
float _orbitYaw;
float _orbitPitch;
TimeSince _lastMouseInput = 999f;
protected override void OnStart()
{
_camera = Components.Get<CameraComponent>();
// Drive mode needs a locked cursor or AnalogLook stays at zero. UI modals unlock
// it; we re-lock every frame when none are open (panels only set Visible while up).
if ( !AnyCursorModalOpen )
Mouse.Visibility = MouseVisibility.Hidden;
}
protected override void OnUpdate()
{
if ( Target is null || !Target.IsValid() )
return;
bool uiOwnsCursor = AnyCursorModalOpen;
if ( !uiOwnsCursor )
{
// Re-assert every frame — panel dismiss paths miss edge cases, and the engine
// can resurface the cursor after focus changes.
Mouse.Visibility = MouseVisibility.Hidden;
var look = Input.AnalogLook;
if ( MathF.Abs( look.yaw ) > 0.05f || MathF.Abs( look.pitch ) > 0.05f )
{
_lastMouseInput = 0;
_orbitYaw += look.yaw;
_orbitPitch = Math.Clamp( _orbitPitch + look.pitch, -30f, 12f );
}
// Scroll up = closer (smaller Distance). Ignore while UI has the cursor.
float scroll = Input.MouseWheel.y;
if ( MathF.Abs( scroll ) > 0.01f )
Distance = Math.Clamp( Distance - scroll * ZoomPerNotch, MinDistance, MaxDistance );
}
if ( !uiOwnsCursor && _lastMouseInput > OrbitReturnDelay )
{
// ease back behind the car
float decay = 1f - MathF.Exp( -3f * Time.Delta );
_orbitYaw = MathX.Lerp( _orbitYaw, 0f, decay );
_orbitPitch = MathX.Lerp( _orbitPitch, 0f, decay );
}
float m = Units.MetersToUnits;
var car = Target.GameObject;
// follow the flattened heading so rolls/flips don't whip the camera
var flatForward = car.WorldRotation.Forward.WithZ( 0f );
flatForward = flatForward.IsNearZeroLength ? Vector3.Forward : flatForward.Normal;
float baseYaw = MathF.Atan2( flatForward.y, flatForward.x ).RadianToDegree();
var orbit = Rotation.From( _orbitPitch, baseYaw + _orbitYaw, 0f );
float speedStretch = Math.Clamp( Target.SpeedMs / 40f, 0f, 1f );
var wantedPos = car.WorldPosition
- orbit.Forward * (Distance + speedStretch * 1.5f) * m
+ Vector3.Up * Height * m;
float smooth = 1f - MathF.Exp( -8f * Time.Delta );
WorldPosition = Vector3.Lerp( WorldPosition, wantedPos, smooth );
var lookTarget = car.WorldPosition + Vector3.Up * 0.8f * m + orbit.Forward * 1.5f * m;
WorldRotation = Rotation.LookAt( (lookTarget - WorldPosition).Normal );
if ( _camera.IsValid() )
_camera.FieldOfView = BaseFov + speedStretch * 15f;
}
}