A scene component that represents the main menu camera for the game. It computes a drifting, swaying camera transform (optionally lerping to a child named "End"), exposes tunable properties for drift, push, FOV and loop time, sets a static Current reference while enabled, and draws editor gizmos showing the camera frustum and path to End.
using Sandbox;
using System;
using System.Linq;
/// <summary>
/// Where the camera sits behind the main menu, the course settings and the lobby. Place one in a
/// scene (the menu_camera prefab) and the fly-over's menu orbit is replaced by this view: the
/// object's position and rotation with a gentle sway and a slight push-in. A child object named
/// End makes it drift back and forth between the two points as well. Without one in the scene
/// the old orbit round the start hole is used. Editor gizmo: the camera's frustum, and the path
/// to End when there is one.
/// </summary>
[Title( "Golf Menu Camera" ), Icon( "videocam" ), Category( "Golf" )]
public sealed class GolfMenuCamera : Component
{
/// <summary>The menu camera in the loaded scene, or null.</summary>
public static GolfMenuCamera Current { get; private set; }
/// <summary>How far the sway moves the camera sideways and up, in units. 0 holds it still.</summary>
[Property, Title( "Drift Amount" ), Range( 0f, 200f )]
public float DriftAmount { get; set; } = 24f;
/// <summary>Seconds for one full sway back and forth.</summary>
[Property, Title( "Drift Period" ), Range( 2f, 60f )]
public float DriftPeriod { get; set; } = 14f;
/// <summary>How far the camera eases forward and back along its view, in units.</summary>
[Property, Title( "Push In" ), Range( 0f, 400f )]
public float PushIn { get; set; } = 40f;
/// <summary>Degrees of slow look-around that go with the sway. 0 keeps the rotation fixed.</summary>
[Property, Title( "Sway Degrees" ), Range( 0f, 10f )]
public float SwayDegrees { get; set; } = 1.5f;
/// <summary>Seconds for one trip from here to End and back, when a child named End exists.</summary>
[Property, Title( "Loop Seconds" ), Range( 4f, 120f )]
public float LoopSeconds { get; set; } = 30f;
/// <summary>Vertical field of view of the menu view.</summary>
[Property, Title( "Field Of View" ), Range( 30f, 110f )]
public float FieldOfView { get; set; } = 70f;
protected override void OnEnabled()
{
Current = this;
}
protected override void OnDisabled()
{
if ( Current == this ) Current = null;
}
/// <summary>The End child, if the camera loops between two points.</summary>
GameObject End => GameObject.Children.FirstOrDefault( c => c.IsValid() && string.Equals( c.Name, "End", StringComparison.OrdinalIgnoreCase ) );
/// <summary>The camera's position and rotation at a moment in time, drift and all.</summary>
public Transform ViewAt( float time )
{
var start = WorldTransform;
var position = start.Position;
var rotation = start.Rotation;
// Back and forth between here and End, easing at both ends.
var end = End;
if ( end.IsValid() && LoopSeconds > 0f )
{
var phase = (time % LoopSeconds) / LoopSeconds; // 0..1 over one round trip
var t = phase < 0.5f ? phase * 2f : 2f - phase * 2f; // 0 -> 1 -> 0
t = t * t * (3f - 2f * t); // smoothstep
position = Vector3.Lerp( start.Position, end.WorldPosition, t );
rotation = Rotation.Slerp( start.Rotation, end.WorldRotation, t );
}
// The sway: a slow figure of eight sideways and up, with a push-in on a longer beat.
var period = MathF.Max( 2f, DriftPeriod );
var w = MathF.Tau * time / period;
var sideways = MathF.Sin( w ) * DriftAmount;
var upward = MathF.Sin( w * 0.5f + 1.3f ) * DriftAmount * 0.5f;
var forward = (0.5f + 0.5f * MathF.Sin( w * 0.25f )) * PushIn;
var swayed = rotation * Rotation.From( MathF.Sin( w * 0.5f ) * SwayDegrees * 0.5f, MathF.Sin( w ) * SwayDegrees, 0f );
position += rotation.Right * sideways + rotation.Up * upward + rotation.Forward * forward;
return new Transform( position, swayed );
}
protected override void DrawGizmos()
{
var selected = Gizmo.IsSelected || Gizmo.IsHovered;
var colour = new Color( 0.37f, 0.83f, 0.42f );
Gizmo.Draw.LineThickness = selected ? 2f : 1f;
Gizmo.Draw.Color = colour.WithAlpha( selected ? 0.95f : 0.55f );
// The frustum in local space: the near and far rectangles of the field of view, joined up.
var aspect = 16f / 9f;
var near = 20f;
var far = selected ? 600f : 300f;
var tan = MathF.Tan( MathX.DegreeToRadian( FieldOfView ) * 0.5f );
Vector3[] Corners( float d )
{
var h = tan * d;
var w = h * aspect;
return new[]
{
new Vector3( d, -w, -h ), new Vector3( d, w, -h ),
new Vector3( d, w, h ), new Vector3( d, -w, h ),
};
}
var n = Corners( near );
var f = Corners( far );
for ( var i = 0; i < 4; i++ )
{
Gizmo.Draw.Line( n[i], n[(i + 1) % 4] );
Gizmo.Draw.Line( f[i], f[(i + 1) % 4] );
Gizmo.Draw.Line( Vector3.Zero, f[i] );
}
// A small body so it can be found and clicked.
Gizmo.Draw.LineBBox( new BBox( new Vector3( -14f, -8f, -8f ), new Vector3( 4f, 8f, 8f ) ) );
// The loop path to End, in world space.
var end = End;
if ( end.IsValid() )
{
var local = WorldTransform.PointToLocal( end.WorldPosition );
Gizmo.Draw.Color = colour.WithAlpha( 0.8f );
Gizmo.Draw.Arrow( Vector3.Zero, local, 12f, 4f );
}
}
}