Hole.cs

Component representing a golf hole. It stores UI title, par, tee, and world or relative playable bounds, detects balls entering the cup on trigger enter (host only) and reports it via IGolfCourseEvents, and provides editor helpers to fit and edit the bounds with gizmos and undo support.

NetworkingFile Access
using Sandbox;
using System;

/// <summary>
/// One hole of the course: where its balls tee off, where the green ends, and the scoring
/// trigger at the bottom of its cup. Put this on a GameObject together with a trigger collider
/// (Is Trigger ticked) placed inside the cup, below the opening in the green. The ball gets
/// there by physics alone: it has to roll over the opening slowly enough to drop in.
///
/// When a ball reaches the trigger this reports it (<see cref="IGolfCourseEvents.OnBallInCup"/>)
/// and the game finishes its owner; a ball nobody has shot is simply put back at the tee by the game.
/// </summary>
[Title( "Golf Hole" ), Icon( "golf_course" )]
public sealed class Hole : Component, Component.ITriggerListener
{
	/// <summary>
	/// Name shown on the HUD.
	/// </summary>
	[Property]
	public string Title { get; set; } = "";

	/// <summary>
	/// Expected number of shots.
	/// </summary>
	[Property, Range( 1, 10 )]
	public int Par { get; set; } = 3;

	/// <summary>
	/// Where every ball for this hole spawns. If empty, the hole's own position is used.
	/// </summary>
	[Property]
	public GameObject Tee { get; set; }

	/// <summary>
	/// Lowest corner of the playable area, in world units. A ball outside the box is out of bounds.
	/// </summary>
	[Property, Group( "Bounds" )]
	public Vector3 BoundsMin { get; set; } = new Vector3( -640f, -640f, -120f );

	/// <summary>
	/// Highest corner of the playable area, in world units.
	/// </summary>
	[Property, Group( "Bounds" )]
	public Vector3 BoundsMax { get; set; } = new Vector3( 640f, 640f, 4000f );

	/// <summary>
	/// When on, Bounds Min and Bounds Max are measured from this object (the cup) instead of
	/// being world coordinates. The hole kit's cup pieces use this, so a hole can be moved freely.
	/// </summary>
	[Property, Group( "Bounds" )]
	public bool RelativeBounds { get; set; } = false;

	/// <summary>Room left round the hole's pieces by Fit To Pieces, in units. The top gets 800.</summary>
	[Property, Group( "Bounds" ), Range( 0f, 2000f )]
	public float Margin { get; set; } = 200f;

	/// <summary>
	/// Where the balls start: the Tee object if set, otherwise a <see cref="GolfTee"/> under the
	/// same parent group (the kit's Tee Piece), otherwise this object's own position.
	/// </summary>
	public Vector3 TeePosition
	{
		get
		{
			if ( Tee.IsValid() ) return Tee.WorldPosition;

			var tee = GameObject.Parent?.GetComponentInChildren<GolfTee>( true );
			return tee.IsValid() ? tee.WorldPosition : WorldPosition;
		}
	}

	/// <summary>
	/// True if a world position is inside the playable area.
	/// </summary>
	public bool Contains( Vector3 position )
	{
		var min = BoundsMin;
		var max = BoundsMax;
		if ( RelativeBounds )
		{
			min += WorldPosition;
			max += WorldPosition;
		}

		return position.x >= min.x && position.x <= max.x
			&& position.y >= min.y && position.y <= max.y
			&& position.z >= min.z && position.z <= max.z;
	}

	void Component.ITriggerListener.OnTriggerEnter( Collider other )
	{
		// Physics runs everywhere, but only the host decides what counts.
		if ( !Networking.IsHost ) return;
		if ( !other.IsValid() || !other.GameObject.IsValid() ) return;

		var ball = other.GameObject.GetComponentInParent<IGolfCourseBall>();
		if ( ball is null || ball.IsHoled ) return;

		IGolfCourseEvents.Post( x => x.OnBallInCup( this, ball ) );
	}

	// ---------------------------------------------------------------- editor: the bounds box

	/// <summary>The bounds as a world-space box.</summary>
	BBox WorldBounds
	{
		get
		{
			var min = BoundsMin;
			var max = BoundsMax;
			if ( RelativeBounds )
			{
				min += WorldPosition;
				max += WorldPosition;
			}
			return new BBox( min, max );
		}
	}

	/// <summary>Writes a world-space box back to Bounds Min and Bounds Max.</summary>
	void SetWorldBounds( BBox box )
	{
		var min = box.Mins;
		var max = box.Maxs;
		if ( RelativeBounds )
		{
			min -= WorldPosition;
			max -= WorldPosition;
		}
		BoundsMin = min;
		BoundsMax = max;
	}

	/// <summary>Changes the bounds in the editor as one undo step.</summary>
	void ChangeBounds( string what, Vector3 min, Vector3 max )
	{
		if ( BoundsMin == min && BoundsMax == max ) return;
		using var undo = Scene.Editor?.UndoScope( what ).WithComponentChanges( this ).Push();
		BoundsMin = min;
		BoundsMax = max;
	}

	/// <summary>The "Hole N" group this cup belongs to: the nearest ancestor named like one, else the parent, else the cup itself.</summary>
	GameObject HoleGroup
	{
		get
		{
			GameObject fallback = null;
			for ( var go = GameObject.Parent; go.IsValid() && go is not Sandbox.Scene; go = go.Parent )
			{
				fallback ??= go;
				if ( System.Text.RegularExpressions.Regex.IsMatch( go.Name ?? "", @"^Hole \d+" ) ) return go;
			}
			return fallback ?? GameObject;
		}
	}

	/// <summary>
	/// Sets the bounds to every renderer under the hole group (the cup's parent group), in world
	/// space, plus <see cref="Margin"/> on each side in X and Y, 120 below the lowest piece and
	/// 800 above the highest; then into the cup-relative space when Relative Bounds is on.
	/// </summary>
	[Button( "Fit to pieces", "fit_screen" )]
	public void FitToPieces()
	{
		var group = HoleGroup;
		BBox? box = null;
		var count = 0;
		foreach ( var renderer in group.GetComponentsInChildren<ModelRenderer>( true ) )
		{
			if ( !renderer.IsValid() ) continue;
			var b = renderer.Bounds;
			if ( b.Size.Length < 0.01f ) continue;
			box = box is null ? b : box.Value.AddBBox( b );
			count++;
		}
		if ( box is null )
		{
			Log.Warning( $"Golf Hole: no pieces found under {group.Name} to fit the bounds to" );
			return;
		}

		var world = new BBox( box.Value.Mins + new Vector3( -Margin, -Margin, -120f ), box.Value.Maxs + new Vector3( Margin, Margin, 800f ) );
		var min = world.Mins;
		var max = world.Maxs;
		if ( RelativeBounds )
		{
			min -= WorldPosition;
			max -= WorldPosition;
		}
		ChangeBounds( "Fit hole bounds to pieces", min, max );
		Log.Info( $"Golf Hole: {GameObject.Name} bounds fitted to {count} renderers under {group.Name}: world {world.Mins} .. {world.Maxs}{( RelativeBounds ? $", relative {min} .. {max}" : "" )}" );
	}

	// A handle drag: the world box when it started (for the log and a clean undo step), the box
	// as dragged before any grid snapping (so snapping never swallows small moves), and the open
	// undo scope, from the first change until the mouse is released.
	BBox? _dragStart;
	BBox? _dragBox;
	System.IDisposable _dragUndo;

	/// <summary>
	/// Moves one face of the world box by a distance along its outward normal and writes the
	/// result back (cup-relative when Relative Bounds is on). The distance is in world units: the
	/// handles hand over the mouse's movement along the arrow already mapped from screen to world
	/// at the handle's depth. With snapping the moved face lands on the grid; the other faces stay.
	/// Faces never cross: a face stops one unit short of its opposite.
	/// </summary>
	public void DragFace( Vector3 normal, float distance, bool snap = false, float gridSpacing = 0f )
	{
		var box = _dragBox ?? WorldBounds;
		var min = box.Mins;
		var max = box.Maxs;
		var outMin = min;
		var outMax = max;
		for ( var i = 0; i < 3; i++ )
		{
			if ( normal[i] > 0.5f )
			{
				max[i] = MathF.Max( min[i] + 1f, max[i] + distance );
				outMax[i] = snap && gridSpacing > 0f ? MathF.Max( min[i] + 1f, max[i].SnapToGrid( gridSpacing ) ) : max[i];
			}
			else if ( normal[i] < -0.5f )
			{
				min[i] = MathF.Min( max[i] - 1f, min[i] - distance );
				outMin[i] = snap && gridSpacing > 0f ? MathF.Min( max[i] - 1f, min[i].SnapToGrid( gridSpacing ) ) : min[i];
			}
		}
		_dragBox = new BBox( min, max );
		SetWorldBounds( new BBox( outMin, outMax ) );
	}

	static readonly Vector3[] FaceNormals = { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right, Vector3.Up, Vector3.Down };

	/// <summary>
	/// Editor: the out-of-bounds box as a translucent red wireframe while the cup is selected, with
	/// an arrow handle on each face. The handles are the engine's arrow control, which hands back
	/// the mouse movement along the arrow each frame; that movement is applied to the live bounds,
	/// so nothing depends on another control's state. The box is world-aligned, so everything is
	/// done in a world-space scope. The handles keep a fixed screen size.
	/// </summary>
	protected override void DrawGizmos()
	{
		if ( !Gizmo.Pressed.Any && _dragUndo is not null )
		{
			Log.Info( $"Golf Hole: {GameObject.Name} bounds drag ended: {( _dragStart is { } s ? $"{s.Mins} .. {s.Maxs}" : "?" )} -> {WorldBounds.Mins} .. {WorldBounds.Maxs} (min {BoundsMin}, max {BoundsMax})" );
			_dragUndo.Dispose();
			_dragUndo = null;
			_dragStart = null;
			_dragBox = null;
		}

		if ( !Gizmo.IsSelected ) return;

		using var scope = Gizmo.Scope( "hole-bounds" );
		Gizmo.Transform = global::Transform.Zero;

		var box = WorldBounds;
		Gizmo.Draw.IgnoreDepth = false;
		Gizmo.Draw.LineThickness = 2f;
		Gizmo.Draw.Color = Color.Red.WithAlpha( 0.6f );
		Gizmo.Draw.LineBBox( box );
		Gizmo.Draw.IgnoreDepth = true;
		Gizmo.Draw.Color = Color.Red.WithAlpha( 0.08f );
		Gizmo.Draw.LineBBox( box );

		var centre = box.Center;
		var half = box.Size * 0.5f;
		for ( var i = 0; i < FaceNormals.Length; i++ )
		{
			var normal = FaceNormals[i];
			var faceCentre = centre + normal * Vector3.Dot( half, new Vector3( MathF.Abs( normal.x ), MathF.Abs( normal.y ), MathF.Abs( normal.z ) ) );

			using var face = Gizmo.Scope( $"face{i}", faceCentre );
			// The handle keeps a fixed screen size; the arrow's distance comes from a plane trace
			// under the mouse, so it is already in world units (the engine's position control
			// uses it the same way, without rescaling).
			using var fixedScale = Gizmo.GizmoControls.PushFixedScale();

			Gizmo.Draw.Color = Color.Red.WithAlpha( 0.9f );
			if ( !Gizmo.Control.Arrow( "drag", normal, out var moved, 18f, 5f, 0f, 2f ) || moved == 0f ) continue;

			if ( _dragUndo is null )
			{
				_dragStart = box;
				_dragBox = box;
				_dragUndo = Scene.Editor?.UndoScope( "Resize hole bounds" ).WithComponentChanges( this ).Push();
				Log.Info( $"Golf Hole: {GameObject.Name} bounds drag started on face {normal}: box {box.Mins} .. {box.Maxs}, first move {moved:0.##} world units" );
			}
			var snapping = Gizmo.Settings.SnapToGrid != Gizmo.IsCtrlPressed;
			DragFace( normal, moved, snapping, Gizmo.Settings.GridSpacing );
		}
	}
}