s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (40 total matches found)
mrtn.golfkit / Code/GolfBarbedWire.cs
Game library
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Barbed wire: a trigger volume on the wire model. A player in it walks at a fraction of their
/// speed and is scraped for a share of their health every so often, playing and finished players
/// alike, with a scrape sound; a player who hasn't finished the hole is never killed by it (the
/// scrape stops at 1 hp for them). A ball in it is dragged down towards a fraction of its speed
/// while it stays in, without being blocked. The host decides everything from what the trigger
/// is touching each physics step: who is inside, when a scrape is due and the ball drag. Players
/// are the bodies tagged "player"; the damage and the slow are the game's, told through
/// <see cref="IGolfCourseEvents"/> (entered, left, scrape). The scrape sound is played from here
/// when the game asks (<see cref="PlayScrape"/>).
/// </summary>
[Title( "Golf Barbed Wire" ), Icon( "fence" )]
public sealed class GolfBarbedWire : Component
{
	/// <summary>Damage tag of a scrape, so the golf damage rules let it through.</summary>
	public const string DamageTag = "barbedwire";

	/// <summary>Move speed in the wire as a fraction of normal: 0.5 is half speed.</summary>
	[Property, Title( "Slow Factor" ), Range( 0.1f, 1f )]
	public float SlowFactor { get; set; } = 0.5f;

	/// <summary>Health taken per scrape, as a percentage of max health.</summary>
	[Property, Title( "Damage Percent" ), Range( 0f, 100f )]
	public float DamagePercent { get; set; } = 10f;

	/// <summary>Seconds between scrapes while a player stays in the wire. The first scrape is on entry.</summary>
	[Property, Title( "Damage Interval" ), Range( 0.25f, 10f )]
	public float DamageInterval { get; set; } = 2f;

	/// <summary>
	/// How fast a ball in the wire loses speed, per second, towards <see cref="SlowFactor"/> of the
	/// fastest it has gone since it came in. 6 takes it most of the way in a third of a second.
	/// </summary>
	[Property, Title( "Ball Drag" ), Range( 0.1f, 20f )]
	public float BallDrag { get; set; } = 6f;

	/// <summary>The scrape, played at the player for everyone nearby. Empty: a metal scrape from the engine.</summary>
	[Property, Group( "Sound" )]
	public SoundEvent ScrapeSound { get; set; }

	[Property, Group( "Sound" ), Range( 0f, 3f )]
	public float ScrapeVolume { get; set; } = 0.6f;

	[Property, Group( "Sound" ), Range( 0.3f, 2f )]
	public float ScrapePitch { get; set; } = 0.8f;

	const string DefaultScrape = "sounds/impacts/melee/impact-melee-metal.sound";

	/// <summary>The tag on a player's body; the object carrying it (not a child that inherits it) is the walker.</summary>
	public const string WalkerTag = "player";

	// Host: which bodies are in this wire and how long since their last scrape; which balls are in
	// it and the fastest each has gone since it came in.
	readonly Dictionary<GameObject, RealTimeSince> _walkers = new();
	readonly Dictionary<IGolfCourseBall, float> _balls = new();
	readonly HashSet<GameObject> _walkersNow = new();
	readonly HashSet<IGolfCourseBall> _ballsNow = new();

	Collider _trigger;

	protected override void OnStart()
	{
		_trigger = GetComponent<Collider>();
		if ( !_trigger.IsValid() || !_trigger.IsTrigger )
			Log.Warning( $"GolfBarbedWire: {GameObject.Name} has no trigger collider; the wire does nothing" );
	}

	protected override void OnDisabled()
	{
		if ( !Networking.IsHost ) return;
		foreach ( var walker in _walkers.Keys.ToList() )
			Leave( walker, "wire disabled" );
		_walkers.Clear();
		_balls.Clear();
	}

	/// <summary>The walker a touched collider belongs to: the nearest object up the chain that carries the tag itself.</summary>
	static GameObject WalkerOf( GameObject touched )
	{
		for ( var go = touched; go.IsValid() && go is not global::Sandbox.Scene; go = go.Parent )
			if ( go.Tags.Has( WalkerTag, false ) ) return go;
		return null;
	}

	protected override void OnFixedUpdate()
	{
		if ( !Networking.IsHost || !_trigger.IsValid() ) return;

		// What the trigger overlaps right now, from the physics itself: no enter or exit event to miss.
		_walkersNow.Clear();
		_ballsNow.Clear();
		foreach ( var other in _trigger.Touching )
		{
			if ( !other.IsValid() || !other.GameObject.IsValid() ) continue;
			var walker = WalkerOf( other.GameObject );
			if ( walker.IsValid() ) { _walkersNow.Add( walker ); continue; }
			var ball = other.GameObject.GetComponentInParent<IGolfCourseBall>();
			if ( ball is not null && ball.GameObject.IsValid() ) _ballsNow.Add( ball );
		}

		// Walkers: newcomers are reported and scraped at once, stayers on the interval, leavers released.
		foreach ( var walker in _walkers.Keys.ToList() )
		{
			if ( walker.IsValid() && _walkersNow.Contains( walker ) ) continue;
			Leave( walker, walker.IsValid() ? "left" : "gone" );
			_walkers.Remove( walker );
		}
		foreach ( var walker in _walkersNow )
		{
			if ( !_walkers.ContainsKey( walker ) )
			{
				_walkers[walker] = 0f;
				Log.Info( $"GolfBarbedWire: {walker.Name} entered the wire at {GameObject.Name}" );
				IGolfCourseEvents.Post( x => x.OnWireEntered( this, walker ) );
				IGolfCourseEvents.Post( x => x.OnWireScrape( this, walker ) );
				continue;
			}

			if ( _walkers[walker] < DamageInterval ) continue;
			_walkers[walker] = 0f;
			IGolfCourseEvents.Post( x => x.OnWireScrape( this, walker ) );
		}

		// Balls: dragged towards the slow factor of the fastest they've gone in here.
		foreach ( var ball in _balls.Keys.ToList() )
		{
			var alive = ball.GameObject.IsValid();
			if ( alive && _ballsNow.Contains( ball ) ) continue;
			Log.Info( $"GolfBarbedWire: {( alive ? ball.OwnerName : "?" )}'s ball left the wire at {GameObject.Name}, speed {( alive && ball.Rigidbody.IsValid() ? ball.Rigidbody.Velocity.Length : 0f ):0}" );
			_balls.Remove( ball );
		}
		foreach ( var ball in _ballsNow )
		{
			if ( !ball.Rigidbody.IsValid() ) continue;
			var body = ball.Rigidbody;
			var velocity = body.Velocity;
			var speed = velocity.Length;

			if ( !_balls.ContainsKey( ball ) )
			{
				_balls[ball] = speed;
				Log.Info( $"GolfBarbedWire: {ball.OwnerName}'s ball entered the wire at {GameObject.Name}, speed {speed:0}" );
			}
			if ( ball.IsHoled ) continue;

			var top = Math.Max( _balls[ball], speed );
			_balls[ball] = top;

			var target = top * SlowFactor;
			if ( speed <= target || speed < 1f ) continue;

			var slowed = MathX.Lerp( speed, target, Math.Clamp( BallDrag * Time.Delta, 0f, 1f ) );
			body.Velocity = velocity * (slowed / speed);
		}
	}

	void Leave( GameObject walker, string why )
	{
		Log.Info( $"GolfBarbedWire: {( walker.IsValid() ? walker.Name : "a body" )} {why} the wire at {GameObject.Name}" );
		IGolfCourseEvents.Post( x => x.OnWireLeft( this, walker ) );
	}

	/// <summary>The scrape sound at a position, for everyone nearby. The game calls this when a scrape did damage.</summary>
	public void PlayScrape( Vector3 position )
	{
		ScrapeRpc( position, ScrapeSound?.ResourcePath ?? DefaultScrape, ScrapeVolume, ScrapePitch );
	}

	[Rpc.Broadcast]
	static void ScrapeRpc( Vector3 position, string path, float volume, float pitch )
	{
		var sound = ResourceLibrary.Get<SoundEvent>( string.IsNullOrEmpty( path ) ? DefaultScrape : path );
		if ( sound is null || volume <= 0f ) return;
		var handle = Sound.Play( sound, position );
		if ( !handle.IsValid() ) return;
		handle.Volume = volume;
		handle.Pitch = pitch;
	}
}
mrtn.golfkit / Code/Hole.cs
Game library
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 );
		}
	}
}
mrtn.golfkit / GolfRotator.cs
Game library
using Sandbox;
using System;
using System.Linq;

/// <summary>
/// Spins the object it sits on round one of its own axes at a steady rate, on every machine and
/// in the editor too (nothing is networked: every machine turns it the same way from the same
/// start, and a prop that is a little out of step between machines does not matter). Made for
/// the windmill's blades, whose axle is their local Y.
///
/// With a Rigidbody on the same object (motion off, so the body is keyframed in play) the turn
/// goes through the physics body with a matching angular velocity, and on the host the box
/// colliders on the object also push golf balls by hand: a ball inside the sweep is kept awake,
/// and a ball an arm box overlaps is given the arm's surface speed at the contact point, so
/// resting balls get swatted instead of being passed through.
/// </summary>
[Title( "Golf Rotator" ), Icon( "sync" ), Category( "Golf" )]
public sealed class GolfRotator : Component, Component.ExecuteInEditor
{
	/// <summary>The local axis to turn round. The windmill blades turn round their local Y.</summary>
	[Property]
	public Vector3 Axis { get; set; } = new Vector3( 0f, 1f, 0f );

	/// <summary>Turning speed in degrees per second; negative turns the other way.</summary>
	[Property, Title( "Degrees Per Second" ), Range( -360f, 360f )]
	public float DegreesPerSecond { get; set; } = 30f;

	Rigidbody _body;
	BoxCollider[] _arms = Array.Empty<BoxCollider>();
	float _reach;
	bool _loggedContact;

	protected override void OnStart()
	{
		_body = GetComponent<Rigidbody>( true );
		_arms = GetComponents<BoxCollider>( true ).ToArray();
		_reach = _arms.Length == 0 ? 0f : _arms.Max( a => a.Center.Length + a.Scale.Length * 0.5f );
	}

	protected override void OnUpdate()
	{
		// In play with a physics body the fixed step below does the turning.
		if ( !Scene.IsEditor && _body.IsValid() ) return;
		Turn( Time.Delta );
	}

	protected override void OnFixedUpdate()
	{
		if ( Scene.IsEditor || !_body.IsValid() ) return;
		var next = Turn( Time.Delta );
		if ( next is null ) return;

		// The keyframed body is rotated with the velocity that gets it there this step, so a ball
		// in the blade's path is pushed rather than finding the blade somewhere else next step.
		_body.SmoothRotate( next.Value, Time.Delta, Time.Delta );

		if ( Networking.IsHost ) PushBalls();
	}

	/// <summary>Turns the object by one step and returns the new world rotation, or null when there is nothing to do.</summary>
	Rotation? Turn( float seconds )
	{
		var axis = Axis;
		if ( axis.IsNearlyZero() || DegreesPerSecond == 0f || seconds <= 0f ) return null;
		LocalRotation *= Rotation.FromAxis( axis.Normal, DegreesPerSecond * seconds );
		return WorldRotation;
	}

	/// <summary>
	/// Host only. Every ball within the sweep is kept awake (a sleeping ball ignores a keyframed
	/// body sliding into it), and a ball that an arm box overlaps gets at least the arm's surface
	/// velocity along the contact normal and is lifted out of the box.
	/// </summary>
	void PushBalls()
	{
		if ( _arms.Length == 0 || DegreesPerSecond == 0f ) return;

		var hub = WorldPosition;
		var omega = WorldRotation * Axis.Normal * DegreesPerSecond.DegreeToRadian();
		var transform = WorldTransform;

		foreach ( var ball in Scene.GetAllComponents<IGolfCourseBall>() )
		{
			if ( !ball.GameObject.IsValid() || ball.IsHoled || !ball.Rigidbody.IsValid() ) continue;

			var pos = ball.WorldPosition;
			if ( pos.Distance( hub ) > _reach + ball.Radius + 4f ) continue;

			ball.Rigidbody.Sleeping = false;

			foreach ( var arm in _arms )
			{
				if ( !arm.IsValid() || !arm.Enabled ) continue;

				// Closest point of the arm box to the ball's centre, in the box's own frame.
				var local = transform.PointToLocal( pos ) - arm.Center;
				var half = arm.Scale * 0.5f;
				var clamped = new Vector3( Math.Clamp( local.x, -half.x, half.x ), Math.Clamp( local.y, -half.y, half.y ), Math.Clamp( local.z, -half.z, half.z ) );
				var closest = transform.PointToWorld( clamped + arm.Center );
				var gap = pos - closest;
				var dist = gap.Length;
				if ( dist > ball.Radius ) continue;

				var surface = Vector3.Cross( omega, closest - hub );
				var normal = dist > 0.05f ? gap.Normal : surface.Normal;
				if ( normal.IsNearlyZero() ) continue;

				// Match the arm's speed into the ball; a ball already moving away is left alone.
				var velocity = ball.Rigidbody.Velocity;
				var relative = (velocity - surface).Dot( normal );
				if ( relative < 0f ) ball.Rigidbody.Velocity = velocity - normal * relative;

				// Lift it out of the box so the next step starts from a clean contact.
				if ( dist < ball.Radius - 0.1f ) ball.WorldPosition = pos + normal * (ball.Radius - dist);

				if ( !_loggedContact )
				{
					_loggedContact = true;
					Log.Info( $"GolfRotator: {GameObject.Name} first touched {ball.OwnerName}'s ball at {closest}, arm surface speed {surface.Length:0}, ball {velocity.Length:0} -> {ball.Rigidbody.Velocity.Length:0} u/s" );
				}

				break;
			}
		}
	}
}
mrtn.golfkit / TrenchFire.cs
Game library
using Sandbox;
using System;

/// <summary>
/// Decorative machine-gun fire for the trench map: bursts of glowing tracers fly from a random
/// point on the Start line to a random point on the End line, with a muzzle flash where they leave
/// and a dust puff where they land, and a distant, softened shot sound for every round. Purely a
/// show: nothing is traced against players or balls, nothing takes damage, nothing is networked.
/// Every machine runs its own with its own random timing.
///
/// The two lines are each two child objects (Start A/B, End A/B) that you move with the normal
/// move gizmo; they are created the first time the component is seen in the editor if missing.
/// The lines and the handles are drawn as gizmos. The component's own Enabled checkbox turns the
/// gun off. Small pool of tracers and flash lights, nothing allocated per shot but the engine's
/// own sound handle and the surface's impact particle.
/// </summary>
[Title( "Trench Fire" ), Category( "Golf" ), Icon( "whatshot" )]
public sealed class TrenchFire : Component
{
	/// <summary>One end of the line the tracers leave from.</summary>
	[Property, Group( "Lines" ), Title( "Start A" )]
	public GameObject StartA { get; set; }

	/// <summary>The other end of the line the tracers leave from.</summary>
	[Property, Group( "Lines" ), Title( "Start B" )]
	public GameObject StartB { get; set; }

	/// <summary>One end of the line the tracers land on.</summary>
	[Property, Group( "Lines" ), Title( "End A" )]
	public GameObject EndA { get; set; }

	/// <summary>The other end of the line the tracers land on.</summary>
	[Property, Group( "Lines" ), Title( "End B" )]
	public GameObject EndB { get; set; }

	/// <summary>Random scatter around the landing point, in units.</summary>
	[Property, Group( "Lines" ), Range( 0f, 300f )]
	public float Spread { get; set; } = 40f;

	/// <summary>Bursts per minute.</summary>
	[Property, Group( "Timing" ), Range( 0.1f, 60f ), Title( "Rate (bursts per minute)" )]
	public float Rate { get; set; } = 6f;

	/// <summary>Fewest shots in a burst.</summary>
	[Property, Group( "Timing" ), Range( 1, 60 ), Title( "Burst Length Min" )]
	public int BurstMin { get; set; } = 8;

	/// <summary>Most shots in a burst.</summary>
	[Property, Group( "Timing" ), Range( 1, 60 ), Title( "Burst Length Max" )]
	public int BurstMax { get; set; } = 15;

	/// <summary>Shots per second inside a burst.</summary>
	[Property, Group( "Timing" ), Range( 1f, 30f )]
	public float ShotsPerSecond { get; set; } = 12f;

	/// <summary>How much the pause between bursts varies, 0 for a metronome, 1 for anything from no pause to double.</summary>
	[Property, Group( "Timing" ), Range( 0f, 1f )]
	public float PauseVariance { get; set; } = 0.5f;

	/// <summary>How fast a tracer flies, units per second.</summary>
	[Property, Group( "Tracer" ), Range( 500f, 20000f )]
	public float TracerSpeed { get; set; } = 6000f;

	/// <summary>Colour of the tracer streak, the flash and its light.</summary>
	[Property, Group( "Tracer" )]
	public Color TracerColour { get; set; } = new Color( 1f, 0.55f, 0.15f );

	/// <summary>Length of the streak, in units.</summary>
	[Property, Group( "Tracer" ), Range( 10f, 1000f )]
	public float TracerLength { get; set; } = 160f;

	/// <summary>Width of the streak at its bright end, in units.</summary>
	[Property, Group( "Tracer" ), Range( 0.5f, 10f )]
	public float TracerWidth { get; set; } = 2.5f;

	/// <summary>How much brighter than the colour the streak glows.</summary>
	[Property, Group( "Tracer" ), Range( 1f, 8f )]
	public float Glow { get; set; } = 3f;

	/// <summary>A short flash of light at the start point for every shot.</summary>
	[Property, Group( "Effects" )]
	public bool MuzzleFlash { get; set; } = true;

	/// <summary>The surface's bullet impact particle where a tracer lands.</summary>
	[Property, Group( "Effects" )]
	public bool DustPuff { get; set; } = true;

	/// <summary>The distant shot, played at the start point for every round. Empty uses sounds/trench_fire.</summary>
	[Property, Group( "Sound" )]
	public SoundEvent ShotSound { get; set; }

	/// <summary>Volume of the shots, 0 to 1.</summary>
	[Property, Group( "Sound" ), Range( 0f, 1f )]
	public float Volume { get; set; } = 0.35f;

	const string DefaultSound = "sounds/trench_fire.sound";
	const float FlashSeconds = 0.05f;
	const float FlashRadius = 160f;

	struct Landing
	{
		public float At;        // Time.Now the tracer arrives; 0 for none pending
		public Vector3 Position;
		public Vector3 Normal;
		public Surface Surface;
	}

	TracerEffect[] _tracers = Array.Empty<TracerEffect>();
	Landing[] _landings = Array.Empty<Landing>();
	PointLight[] _flashes = Array.Empty<PointLight>();
	float[] _flashOff = Array.Empty<float>();
	int _nextTracer;
	int _nextFlash;
	Surface _defaultSurface;
	Color _builtColour;
	float _builtWidth;

	float _nextBurstAt;
	float _nextShotAt;
	int _shotsLeft;

	protected override void OnStart()
	{
		EnsureHandles();
		ShotSound ??= ResourceLibrary.Get<SoundEvent>( DefaultSound );
		_defaultSurface = Surface.FindByName( "default" );
		BuildPool();
		_nextBurstAt = Time.Now + NextPause() * Game.Random.Float( 0f, 1f );
	}

	protected override void OnDisabled()
	{
		_shotsLeft = 0;
		for ( var i = 0; i < _landings.Length; i++ ) _landings[i].At = 0f;
		foreach ( var flash in _flashes ) if ( flash.IsValid() ) flash.Enabled = false;
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor ) return;
		if ( _tracers.Length == 0 ) return;

		var now = Time.Now;

		// Flashes go out, landings puff.
		for ( var i = 0; i < _flashes.Length; i++ )
		{
			if ( _flashOff[i] > 0f && now >= _flashOff[i] )
			{
				_flashOff[i] = 0f;
				if ( _flashes[i].IsValid() ) _flashes[i].Enabled = false;
			}
		}

		for ( var i = 0; i < _landings.Length; i++ )
		{
			if ( _landings[i].At <= 0f || now < _landings[i].At ) continue;
			_landings[i].At = 0f;
			if ( DustPuff )
				BaseCombatWeapon.ImpactPrefab( null, _landings[i].Surface, _landings[i].Position, _landings[i].Normal );
		}

		// Bursts.
		if ( _shotsLeft <= 0 )
		{
			if ( now < _nextBurstAt ) return;
			_shotsLeft = Game.Random.Int( Math.Min( BurstMin, BurstMax ), Math.Max( BurstMin, BurstMax ) );
			_nextShotAt = now;
			_nextBurstAt = now + NextPause();
		}

		if ( now < _nextShotAt ) return;
		_nextShotAt = now + 1f / Math.Max( 1f, ShotsPerSecond );
		_shotsLeft--;
		Fire();
	}

	float NextPause()
	{
		var mean = 60f / Math.Max( 0.1f, Rate );
		return mean * (1f + Game.Random.Float( -PauseVariance, PauseVariance ));
	}

	void Fire()
	{
		if ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;

		var start = Vector3.Lerp( StartA.WorldPosition, StartB.WorldPosition, Game.Random.Float( 0f, 1f ) );
		var end = Vector3.Lerp( EndA.WorldPosition, EndB.WorldPosition, Game.Random.Float( 0f, 1f ) )
			+ Vector3.Random * Spread;

		// Where it lands: the ground near the end point, for the puff's surface and facing.
		var landing = new Landing { Position = end, Normal = Vector3.Up, Surface = _defaultSurface };
		var trace = Scene.Trace.Ray( end + Vector3.Up * 48f, end + Vector3.Down * 96f ).WithoutTags( "player", "golfball" ).Run();
		if ( trace.Hit )
		{
			landing.Position = trace.HitPosition;
			landing.Normal = trace.Normal;
			landing.Surface = trace.Surface ?? _defaultSurface;
		}

		var distance = start.Distance( end );
		landing.At = Time.Now + distance / Math.Max( 1f, TracerSpeed );

		// Tracer: the pool's oldest one restarts from the new start point.
		var i = _nextTracer;
		_nextTracer = (_nextTracer + 1) % _tracers.Length;
		var tracer = _tracers[i];
		if ( tracer.IsValid() )
		{
			if ( _builtColour != TracerColour || _builtWidth != TracerWidth ) BuildLook();
			tracer.Enabled = false;
			tracer.WorldPosition = start;
			tracer.EndPoint = end;
			tracer.DistancePerSecond = TracerSpeed;
			tracer.Length = TracerLength;
			tracer.Enabled = true;
		}
		_landings[i] = landing;

		// Muzzle flash.
		if ( MuzzleFlash && _flashes.Length > 0 )
		{
			var f = _nextFlash;
			_nextFlash = (_nextFlash + 1) % _flashes.Length;
			var flash = _flashes[f];
			if ( flash.IsValid() )
			{
				flash.WorldPosition = start + (end - start).Normal * 12f;
				flash.LightColor = TracerColour * Glow;
				flash.Radius = FlashRadius;
				flash.Enabled = true;
				_flashOff[f] = Time.Now + FlashSeconds;
			}
		}

		// The distant shot.
		if ( ShotSound is not null && Volume > 0f )
		{
			var handle = Sound.Play( ShotSound, start );
			if ( handle.IsValid() ) handle.Volume = Volume;
		}
	}

	/// <summary>
	/// Enough tracers to cover the longest flight at the burst rate, and a couple of flash lights.
	/// Pool objects are hidden children that are never saved or networked.
	/// </summary>
	void BuildPool()
	{
		var longest = 0f;
		if ( StartA.IsValid() && StartB.IsValid() && EndA.IsValid() && EndB.IsValid() )
		{
			longest = Math.Max( Math.Max( StartA.WorldPosition.Distance( EndA.WorldPosition ), StartA.WorldPosition.Distance( EndB.WorldPosition ) ),
				Math.Max( StartB.WorldPosition.Distance( EndA.WorldPosition ), StartB.WorldPosition.Distance( EndB.WorldPosition ) ) );
		}
		var flight = (longest + Spread + TracerLength) / Math.Max( 1f, TracerSpeed );
		var count = Math.Clamp( (int)MathF.Ceiling( flight * ShotsPerSecond ) + 2, 4, 32 );

		_tracers = new TracerEffect[count];
		_landings = new Landing[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = new GameObject( GameObject, false, $"tracer {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var tracer = go.AddComponent<TracerEffect>( false );
			tracer.StartDistance = 0f;
			tracer.Opaque = false;
			tracer.CastShadows = false;
			tracer.EnableLight = false;
			_tracers[i] = tracer;
			go.Enabled = true;
		}
		BuildLook();

		_flashes = new PointLight[2];
		_flashOff = new float[2];
		for ( var i = 0; i < _flashes.Length; i++ )
		{
			var go = new GameObject( GameObject, false, $"flash {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var light = go.AddComponent<PointLight>( false );
			light.Shadows = false;
			light.Radius = FlashRadius;
			light.Attenuation = 2f;
			_flashes[i] = light;
			go.Enabled = true;
		}
	}

	void BuildLook()
	{
		_builtColour = TracerColour;
		_builtWidth = TracerWidth;
		var head = TracerColour * Glow;
		head.a = 1f;
		var colour = new Gradient( new Gradient.ColorFrame( 0f, head ), new Gradient.ColorFrame( 0.35f, TracerColour ), new Gradient.ColorFrame( 1f, TracerColour.WithAlpha( 0f ) ) );
		var width = new Curve( new Curve.Frame( 0f, TracerWidth ), new Curve.Frame( 1f, 0f ) );
		foreach ( var tracer in _tracers )
		{
			if ( !tracer.IsValid() ) continue;
			tracer.LineColor = colour;
			tracer.LineWidth = width;
		}
	}

	/// <summary>
	/// The four handle objects: found by name under this object, or made with default spots.
	/// </summary>
	void EnsureHandles()
	{
		StartA = Handle( StartA, "Start A", new Vector3( 0f, -150f, 40f ) );
		StartB = Handle( StartB, "Start B", new Vector3( 0f, 150f, 40f ) );
		EndA = Handle( EndA, "End A", new Vector3( 1200f, -250f, 0f ) );
		EndB = Handle( EndB, "End B", new Vector3( 1200f, 250f, 0f ) );
	}

	GameObject Handle( GameObject current, string name, Vector3 localPosition )
	{
		if ( current.IsValid() ) return current;

		foreach ( var child in GameObject.Children )
			if ( child.IsValid() && child.Name == name ) return child;

		var go = new GameObject( GameObject, true, name );
		go.LocalPosition = localPosition;
		return go;
	}

	protected override void DrawGizmos()
	{
		EnsureHandles();
		if ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;

		var alpha = Gizmo.IsSelected ? 1f : (Gizmo.IsHovered ? 0.6f : 0.3f);
		var local = WorldTransform;
		var sa = local.PointToLocal( StartA.WorldPosition );
		var sb = local.PointToLocal( StartB.WorldPosition );
		var ea = local.PointToLocal( EndA.WorldPosition );
		var eb = local.PointToLocal( EndB.WorldPosition );

		Gizmo.Draw.LineThickness = Gizmo.IsSelected ? 3f : 2f;
		Gizmo.Draw.Color = TracerColour.WithAlpha( alpha );
		Gizmo.Draw.Line( sa, sb );
		Gizmo.Draw.LineSphere( new Sphere( sa, 8f ), 8 );
		Gizmo.Draw.LineSphere( new Sphere( sb, 8f ), 8 );

		Gizmo.Draw.Color = Color.Red.WithAlpha( alpha );
		Gizmo.Draw.Line( ea, eb );
		Gizmo.Draw.LineSphere( new Sphere( ea, 8f ), 8 );
		Gizmo.Draw.LineSphere( new Sphere( eb, 8f ), 8 );

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.Color = Color.White.WithAlpha( alpha * 0.7f );
		Gizmo.Draw.Arrow( (sa + sb) * 0.5f, (ea + eb) * 0.5f, 24f, 8f );
	}
}
mrtn.golfkit / Editor/KitWallBuilderTool.cs
Editor library
using Sandbox;
using Editor;
using System;
using System.Linq;

/// <summary>
/// Kit Wall Builder: a Scene view tool that lays walls between posts, like Golf With Your Friends'
/// Easy Wall Builder. Click a surface to drop a post; every further click drops the next post and
/// fills a wall between the two, any angle, any length. On a slope a kit wall is a parallelogram
/// (vertical ends, edges along the ground line); a model wall is sheared the same way (Kit Model
/// Wall, a copy of the model's mesh). Right-click or Esc ends the chain; Shift-click afterwards
/// continues from the last post. Pieces go under the selected Hole group (or the scene root), each
/// click with its own undo step.
///
/// The sidebar takes a Wall Model and a Post Model (any model asset, built along its X axis). With
/// no model set, the wall is the hole kit's wall prefab (a Kit Shape with its Length set) and the
/// post a 10 x 10 x 30 kit wall piece. A model gives a plain object with a Model Renderer and a
/// Model Collider, keeping the model's own materials. Fit: Stretch scales one copy to the run,
/// Repeat lays whole copies and trims the last one by scale.
///
/// Collision: Golf Hole Collision merges kit pieces only; a model wall keeps its own Model Collider,
/// which is fine for a wall (the merge exists for floor seams).
/// </summary>
[EditorTool( "kitwall" )]
[Title( "Kit Wall Builder" )]
[Icon( "fence" )]
public sealed class KitWallBuilderTool : EditorTool
{
	public enum WallFit
	{
		/// <summary>One copy of the wall model, scaled along its length to fit between the posts.</summary>
		Stretch,

		/// <summary>Whole copies of the wall model along the run; the last one is trimmed by scale.</summary>
		Repeat,
	}

	const string WallPrefab = "prefabs/holekit/wall.prefab";
	const float PostSize = KitShape.WallThickness;

	/// <summary>Model for the walls; empty means the hole kit wall.</summary>
	[Property, Title( "Wall Model" )]
	public Model WallModel { get; set; }

	/// <summary>Model for the posts; empty means a 10 x 10 x 30 kit wall piece.</summary>
	[Property, Title( "Post Model" )]
	public Model PostModel { get; set; }

	/// <summary>How a wall model fills the run between two posts.</summary>
	[Property, Title( "Fit" )]
	public WallFit Fit { get; set; } = WallFit.Stretch;

	/// <summary>A click this close to a post (in units) uses that post instead of placing a new one.</summary>
	[Property, Title( "Snap Distance" ), Range( 1f, 200f )]
	public float SnapDistance { get; set; } = KitShape.WallThickness;

	Vector3? _lastPost;
	bool _chainOpen;
	readonly System.Collections.Generic.List<Vector3> _chainPosts = new();
	Editor.Label _status;

	public override void OnEnabled()
	{
		// A course whose Golf Course Info says Minigolf gets the hedge set: its wall between posts and
		// its corner piece as the post. Elsewhere the Blender-made kit models, when they exist, then the
		// kit prefab. Model.Load reads them from disk; ResourceLibrary only knows models something already loaded.
		if ( GolfCourseInfo.Find( SceneEditorSession.Active?.Scene )?.WallStyle == GolfCourseInfo.KitWallStyle.Minigolf )
		{
			WallModel ??= LoadKitModel( "models/kit/minigolf/minigolf_wall.vmdl" );
			PostModel ??= LoadKitModel( "models/kit/minigolf/minigolf_corner.vmdl" );
		}
		WallModel ??= LoadKitModel( "models/kit/wall_100.vmdl" );
		PostModel ??= LoadKitModel( "models/kit/post_10.vmdl" );
	}

	internal static Model LoadKitModel( string path )
	{
		var model = Model.Load( path );
		return model is null || model.IsError ? null : model;
	}

	public override Widget CreateToolSidebar()
	{
		var sidebar = new ToolSidebarWidget();
		sidebar.AddTitle( "Kit Wall Builder", "fence" );
		sidebar.MinimumWidth = 320;

		var models = sidebar.AddGroup( "Models" );
		var so = this.GetSerialized();
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( WallModel ) ) ) );
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( PostModel ) ) ) );
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( Fit ) ) ) );
		models.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( SnapDistance ) ) ) );
		models.Add( new Editor.Label( "Empty slots use the hole kit wall. A wall model is built along its X axis." ) );

		var how = sidebar.AddGroup( "How to use" );
		how.Add( new Editor.Label( "Click a surface: a post.\nNext click: a post and a wall between.\nClick an existing post: the wall joins it (the first post closes the loop).\nRight-click or Esc: end the chain.\nShift-click: continue from the last post.\nCtrl+Z: remove the last click." ) );

		var where = sidebar.AddGroup( "Pieces go under" );
		_status = new Editor.Label( ParentName() );
		where.Add( _status );

		return sidebar;
	}

	public override void OnDisabled()
	{
		_lastPost = null;
		_chainOpen = false;
		_chainPosts.Clear();
	}

	public override void OnUpdate()
	{
		if ( _status.IsValid() ) _status.Text = ParentName();

		// Esc ends the chain; so does a right-click.
		if ( Editor.Application.IsKeyDown( KeyCode.Escape ) || Gizmo.WasRightMousePressed )
			_chainOpen = false;

		// The whole view is ours while the tool is active, so clicks don't pick objects.
		Gizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) );

		var trace = Scene.Trace.Ray( Gizmo.CurrentRay, 100000 ).UseRenderMeshes( true ).Run();
		if ( !trace.Hit ) return;

		var parent = FindParent();
		var point = trace.HitPosition;
		var continuing = _lastPost.HasValue && (_chainOpen || Gizmo.IsShiftPressed);

		// Near an existing post: use it exactly, so a loop closes on the post it started from.
		var snapped = NearestPost( parent, point );
		if ( snapped.HasValue ) point = snapped.Value;

		DrawPreview( point, continuing, snapped.HasValue );

		if ( !Gizmo.WasLeftMousePressed ) return;

		if ( snapped.HasValue )
		{
			if ( !continuing || _lastPost.Value.Distance( point ) <= PostSize ) return;   // the post we are standing on
			PlaceWall( parent, _lastPost.Value, point );
			var closedLoop = _chainPosts.Count > 0 && _chainPosts[0].Distance( point ) <= SnapDistance;
			_lastPost = point;
			_chainOpen = !closedLoop;
			if ( closedLoop ) _chainPosts.Clear();
			return;
		}

		if ( continuing && _lastPost.Value.Distance( point ) > PostSize )
			PlaceWall( parent, _lastPost.Value, point );

		PlacePost( parent, point );
		if ( !continuing ) _chainPosts.Clear();
		_chainPosts.Add( point );
		_lastPost = point;
		_chainOpen = true;
	}

	/// <summary>
	/// The base of the nearest post within the snap distance: posts of this chain first, then every
	/// object named Post under the group the pieces go to. Null when none is close.
	/// </summary>
	Vector3? NearestPost( GameObject parent, Vector3 point )
	{
		Vector3? best = null;
		var bestDistance = SnapDistance;

		void Consider( Vector3 basePoint )
		{
			var distance = basePoint.Distance( point );
			if ( distance <= bestDistance ) { bestDistance = distance; best = basePoint; }
		}

		foreach ( var post in _chainPosts ) Consider( post );
		if ( _lastPost.HasValue ) Consider( _lastPost.Value );

		var children = parent.IsValid() ? parent.Children : Scene.Children;
		foreach ( var go in children )
		{
			if ( !go.IsValid() || go.Name != "Post" ) continue;
			var basePoint = go.WorldPosition;
			if ( go.GetComponent<KitShape>() is null && go.GetComponent<ModelRenderer>() is { Model: { } model } )
				basePoint += new Vector3( model.Bounds.Center.x, model.Bounds.Center.y, model.Bounds.Mins.z );
			Consider( basePoint );
		}
		return best;
	}

	/// <summary>The selected Hole group (the selection or one of its parents), else the scene root.</summary>
	GameObject FindParent()
	{
		foreach ( var selected in Selection.OfType<GameObject>() )
		{
			for ( var go = selected; go.IsValid(); go = go.Parent )
			{
				if ( go.GetComponent<GolfHoleCollision>() is not null || go.Name.StartsWith( "Hole ", StringComparison.OrdinalIgnoreCase ) )
					return go;
			}
		}
		return null;
	}

	string ParentName()
	{
		var parent = FindParent();
		return parent.IsValid() ? parent.Name : "the scene root (select a Hole group to build into it)";
	}

	// ---------------------------------------------------------------- placing

	void PlacePost( GameObject parent, Vector3 point )
	{
		var session = SceneEditorSession.Active;
		if ( session is null ) return;
		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Wall builder post" ).WithGameObjectCreations().Push();

		if ( PostModel is null )
		{
			var go = GameObject.Clone( WallPrefab, new Transform( point, Rotation.Identity ), parent, true, "Post" );
			var shape = go?.GetComponent<KitShape>();
			if ( shape is not null ) { shape.Length = PostSize; return; }

			// The kit wall is a model piece: stand it with its bottom centre on the click point.
			var prefabModel = go?.GetComponent<ModelRenderer>()?.Model;
			if ( prefabModel is not null ) go.WorldPosition = point - BottomCentre( prefabModel.Bounds );
			return;
		}

		// The model's bottom centre on the click point.
		var bounds = PostModel.Bounds;
		var bottomCentre = new Vector3( bounds.Center.x, bounds.Center.y, bounds.Mins.z );
		CreateModelObject( parent, "Post", PostModel, point - bottomCentre, Rotation.Identity, Vector3.One );
	}

	void PlaceWall( GameObject parent, Vector3 from, Vector3 to )
	{
		var session = SceneEditorSession.Active;
		if ( session is null ) return;
		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Wall builder wall" ).WithGameObjectCreations().Push();

		// Along the slope from post base to post base, roll kept level.
		var run = to - from;
		var length = run.Length;
		if ( length < 1f ) return;
		var rotation = Rotation.LookAt( run.Normal, Vector3.Up );

		if ( WallModel is null )
		{
			// A kit wall is a parallelogram: level yaw, horizontal length, and the height difference
			// built into the mesh as Rise, so both ends stand vertical on their post bases.
			var flat = run.WithZ( 0f );
			if ( flat.Length < 1f ) return;
			var go = GameObject.Clone( WallPrefab, new Transform( (from + to) * 0.5f, Rotation.LookAt( flat.Normal, Vector3.Up ) ), parent, true, "Wall" );
			var shape = go?.GetComponent<KitShape>();
			if ( shape is not null )
			{
				shape.Length = flat.Length;
				shape.Rise = run.z;
				return;
			}

			// The kit wall is a model piece: stretch it between the posts like a model wall.
			var prefabModel = go?.GetComponent<ModelRenderer>()?.Model;
			if ( prefabModel is not null ) FitModelObject( go, prefabModel, from, rotation, 0f, length );
			return;
		}

		var modelLength = ModelLength( WallModel.Bounds );

		if ( Fit == WallFit.Stretch )
		{
			PlaceWallPiece( parent, WallModel, from, to );
			return;
		}

		// Repeat: whole copies along the run, then the remainder trimmed by scale.
		var direction = run.Normal;
		var copies = (int)MathF.Floor( length / modelLength );
		for ( var i = 0; i < copies; i++ )
			PlaceWallPiece( parent, WallModel, from + direction * (i * modelLength), from + direction * ((i + 1) * modelLength) );

		var rest = length - copies * modelLength;
		if ( rest > 1f )
			PlaceWallPiece( parent, WallModel, from + direction * (copies * modelLength), to );
	}

	/// <summary>
	/// One wall model piece from one point to another, always a Kit Model Wall: the model's mesh
	/// rebuilt at the run's length (so the wall material repeats along the run instead of
	/// stretching with a scaled model) and, on a slope, sheared so the ends stay vertical and the
	/// top follows the ground line. Line Y is the model's local Y that sits on the line between
	/// the points (its bounds centre when not given). Returns the new object, or null for no run.
	/// </summary>
	internal static GameObject PlaceWallPiece( GameObject parent, Model model, Vector3 from, Vector3 to, float? lineY = null )
	{
		var run = to - from;
		var flat = run.WithZ( 0f );
		if ( flat.Length < 1f ) return null;
		var rotation = Rotation.LookAt( flat.Normal, Vector3.Up );

		var wall = new GameObject( true, "Wall" );
		if ( parent.IsValid() ) wall.SetParent( parent, false );
		wall.WorldPosition = from;
		wall.WorldRotation = rotation;
		var shear = wall.AddComponent<KitModelWall>();
		shear.Source = model;
		shear.Length = flat.Length;
		shear.Rise = run.z;
		shear.LineY = lineY ?? model.Bounds.Center.y;
		shear.Material = LoadWallMaterial();
		shear.Rebuild();
		return wall;
	}

	/// <summary>The material on every wall and post the tools make (concrete; the floor is grass).</summary>
	internal const string WallMaterialPath = "textures/tcom_concrete_base8_2x2_1k.vmat";

	internal static Material LoadWallMaterial()
	{
		var material = Material.Load( WallMaterialPath );
		if ( material is null ) Log.Warning( $"Kit wall material {WallMaterialPath} not found; walls and posts keep the model's own material." );
		return material;
	}

	/// <summary>Length of the model along X, the kit's wall axis, at least 1.</summary>
	static float ModelLength( BBox bounds ) => MathF.Max( 1f, bounds.Size.x );

	/// <summary>Bottom centre of the model's bounds, the point that stands on the floor.</summary>
	internal static Vector3 BottomCentre( BBox bounds ) => new( bounds.Center.x, bounds.Center.y, bounds.Mins.z );

	/// <summary>
	/// Places a model object so it covers [start, start + length] along the run from the first post:
	/// its X axis (the kit's wall axis) along the run, scaled on X so its bounds span the length,
	/// Z up, with its bottom on the line between the post bases at the model's local Line Y (its
	/// bounds centre when not given).
	/// </summary>
	internal static void FitModelObject( GameObject go, Model model, Vector3 from, Rotation rotation, float start, float length, float? lineY = null )
	{
		var bounds = model.Bounds;
		var scale = new Vector3( length / ModelLength( bounds ), 1f, 1f );
		var segmentCentre = from + rotation.Forward * (start + length * 0.5f);
		var onLine = new Vector3( bounds.Center.x, lineY ?? bounds.Center.y, bounds.Mins.z );

		go.WorldRotation = rotation;
		go.LocalScale = scale;
		go.WorldPosition = segmentCentre - rotation * (onLine * scale);
	}

	internal static GameObject CreateModelObject( GameObject parent, string name, Model model, Vector3 position, Rotation rotation, Vector3 scale )
	{
		var go = new GameObject( true, name );
		if ( parent.IsValid() ) go.SetParent( parent, false );
		go.WorldPosition = position;
		go.WorldRotation = rotation;
		go.LocalScale = scale;

		var renderer = go.AddComponent<ModelRenderer>();
		renderer.Model = model;
		renderer.MaterialOverride = LoadWallMaterial();

		var collider = go.AddComponent<ModelCollider>();
		collider.Model = model;
		collider.Static = true;
		collider.Surface = Surface.FindByName( GolfHoleCollision.SurfaceName ) ?? collider.Surface;
		return go;
	}

	// ---------------------------------------------------------------- preview

	/// <summary>
	/// A post outline at the cursor (cyan and thicker when it snaps to an existing post), and the
	/// wall footprint from the last post while a chain is on.
	/// </summary>
	void DrawPreview( Vector3 point, bool continuing, bool snapping )
	{
		Gizmo.Draw.Color = snapping ? Color.Cyan : Color.Green;
		Gizmo.Draw.LineThickness = snapping ? 4f : 2f;

		var postBox = PostModel is null
			? new BBox( new Vector3( -PostSize / 2f, -PostSize / 2f, 0f ), new Vector3( PostSize / 2f, PostSize / 2f, KitShape.WallHeight ) )
			: new BBox( PostModel.Bounds.Mins - new Vector3( PostModel.Bounds.Center.x, PostModel.Bounds.Center.y, PostModel.Bounds.Mins.z ), PostModel.Bounds.Maxs - new Vector3( PostModel.Bounds.Center.x, PostModel.Bounds.Center.y, PostModel.Bounds.Mins.z ) );
		using ( Gizmo.Scope( "kitwall-post", new Transform( point, Rotation.Identity ) ) )
			Gizmo.Draw.LineBBox( postBox );

		if ( !continuing ) return;

		var from = _lastPost.Value;
		var run = point - from;
		var length = run.Length;
		if ( length < 1f ) return;

		Gizmo.Draw.Color = Color.Yellow;
		Gizmo.Draw.LineThickness = 2f;

		// The parallelogram both wall kinds make: vertical ends, top and bottom along the ground line.
		var flat = run.WithZ( 0f );
		if ( flat.Length < 1f ) return;
		var yaw = Rotation.LookAt( flat.Normal, Vector3.Up );
		var thickness = WallModel is null ? KitShape.WallThickness : MathF.Max( 1f, WallModel.Bounds.Size.y );
		var height = WallModel is null ? KitShape.WallHeight : MathF.Max( 1f, WallModel.Bounds.Size.z );
		var up = Vector3.Up * height;
		var side = yaw.Left * (thickness / 2f);
		foreach ( var offset in new[] { side, -side } )
		{
			Gizmo.Draw.Line( from + offset, point + offset );
			Gizmo.Draw.Line( from + offset + up, point + offset + up );
			Gizmo.Draw.Line( from + offset, from + offset + up );
			Gizmo.Draw.Line( point + offset, point + offset + up );
		}
		Gizmo.Draw.Line( from + side + up, from - side + up );
		Gizmo.Draw.Line( point + side + up, point - side + up );
	}
}
mrtn.golfkit / GolfHoleCollision.cs
Game library
using Sandbox;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;

/// <summary>
/// One welded collision mesh for a whole hole. Lives on the "Hole N" group (and the Kit Test Hole):
/// collects every enabled Kit Shape piece under the group, takes their collision geometry in hole
/// space, welds shared vertices across the pieces and builds a single static mesh collider with the
/// golf surface. The pieces' own colliders are switched off, their visible meshes stay as they are.
/// A ball rolling over a seam between two pieces then never meets an edge, so it never hops.
///
/// Runs in the editor too and rebuilds itself whenever a piece is added, moved, changed or removed,
/// so a hole is built with pieces and this never needs touching. Nothing is networked: every machine
/// builds the same mesh from the same scene (the piece set and their transforms come from the scene
/// file, and welding is position based, so the result does not depend on order or on who is host).
/// </summary>
public sealed class GolfHoleCollision : Component, Component.ExecuteInEditor
{
	/// <summary>Physics surface for the whole hole (Assets/surface/golf_green.surface).</summary>
	public const string SurfaceName = "golf_green";

	/// <summary>Vertices closer than 1/16 unit are the same vertex. Rotated pieces (walls, corners,
	/// bumpers) land a hair off the grid, this snaps them back onto their neighbours.</summary>
	const float WeldGrid = 16f;

	/// <summary>How often the pieces are checked for changes, in seconds.</summary>
	const float CheckInterval = 0.25f;

	[Property, ReadOnly] public int Pieces { get; private set; }
	[Property, ReadOnly] public int Vertices { get; private set; }
	[Property, ReadOnly] public int Triangles { get; private set; }

	/// <summary>How many hole colliders have been rebuilt since load (see golf_perf).</summary>
	public static int RebuildCount;

	bool _dirty = true;
	int _lastHash;
	TimeSince _sinceCheck;
	ModelCollider _collider;

	/// <summary>A piece under this hole changed: rebuild on the next update.</summary>
	public void MarkDirty() => _dirty = true;

	protected override void OnEnabled()
	{
		_dirty = true;
	}

	protected override void OnDisabled()
	{
		if ( _collider.IsValid() ) _collider.Enabled = false;
	}

	protected override void OnUpdate()
	{
		if ( !_dirty )
		{
			// Polling for moved or edited pieces is for the editor. In play nothing moves a piece
			// except code, and that calls MarkDirty; every rebuild there cooks a physics mesh.
			if ( Game.IsPlaying ) return;
			if ( _sinceCheck < CheckInterval ) return;
			_sinceCheck = 0;
			if ( ComputeHash() == _lastHash ) return;
		}

		Rebuild();
	}

	IEnumerable<KitShape> PiecesBelow() => Components.GetAll<KitShape>( FindMode.EnabledInSelfAndDescendants );

	/// <summary>Model pieces below (a Model Collider with a model, on an object that is not a Kit Shape): the Blender-made kit pieces.</summary>
	// Disabled colliders count too: the merge switches a piece's own collider off, and a saved scene
	// keeps it off, so the piece must still be found on the next load.
	IEnumerable<ModelCollider> ModelPiecesBelow() => Components.GetAll<ModelCollider>( FindMode.EverythingInSelfAndDescendants )
		.Where( c => c.IsValid() && c.GameObject != GameObject && c.GameObject.Enabled && c.Model is not null && c.GetComponent<KitShape>() is null );

	/// <summary>Cheap fingerprint of every piece's transform and settings, for the change check.</summary>
	int ComputeHash()
	{
		var hash = new HashCode();
		hash.Add( 2 ); // bump to force a rebuild after a code change
		var hole = WorldTransform;
		foreach ( var piece in PiecesBelow() )
		{
			// Pieces without collision (flags, tee pads) never change the collider: the swaying flag
			// must not count as a change.
			hash.Add( piece.Collision );
			if ( !piece.Collision ) continue;
			var t = hole.ToLocal( piece.WorldTransform );
			hash.Add( piece.Kind );
			hash.Add( (int)MathF.Round( piece.Length * WeldGrid ) );
			hash.Add( (int)MathF.Round( piece.Rise * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );
			hash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );
			hash.Add( (int)MathF.Round( t.Scale.y * 1000f ) );
			hash.Add( (int)MathF.Round( t.Scale.z * 1000f ) );
			hash.Add( piece.CollisionIndices.Count );
		}
		foreach ( var piece in ModelPiecesBelow() )
		{
			var t = hole.ToLocal( piece.WorldTransform );
			hash.Add( piece.Model.Name );
			hash.Add( (int)MathF.Round( piece.Model.Bounds.Mins.z * WeldGrid ) );
			hash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.x * WeldGrid ) );
			hash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.y * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );
			hash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );
			hash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );
			hash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );
		}
		return hash.ToHashCode();
	}

	void Rebuild()
	{
		var watch = Stopwatch.StartNew();
		RebuildCount++;
		_dirty = false;
		_lastHash = ComputeHash();

		var hole = WorldTransform;
		var positions = new List<Vector3>();
		var indices = new List<int>();
		var lookup = new Dictionary<(int, int, int), int>();
		var pieces = 0;
		var found = 0;

		foreach ( var piece in PiecesBelow() )
		{
			found++;
			// The piece keeps its own collider only when it is not part of a hole.
			var own = piece.GetComponent<ModelCollider>();
			if ( own.IsValid() && own.Enabled ) own.Enabled = false;

			piece.EnsureBuilt();
			if ( !piece.Collision || piece.CollisionIndices.Count == 0 ) continue;
			pieces++;

			var local = hole.ToLocal( piece.WorldTransform );
			var source = piece.CollisionPositions;
			var map = new int[source.Count];
			for ( var i = 0; i < source.Count; i++ )
			{
				var p = local.PointToWorld( source[i] );
				var key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));
				if ( !lookup.TryGetValue( key, out var index ) )
				{
					index = positions.Count;
					positions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );
					lookup[key] = index;
				}
				map[i] = index;
			}

			var tris = piece.CollisionIndices;
			for ( var i = 0; i + 2 < tris.Count; i += 3 )
			{
				int a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];
				if ( a == b || b == c || a == c ) continue; // collapsed by the weld
				indices.Add( a ); indices.Add( b ); indices.Add( c );
			}
		}

		// Blender-made pieces: their model's mesh, welded in the same way; their own collider goes off.
		var modelPieces = 0;
		foreach ( var piece in ModelPiecesBelow().ToList() )
		{
			found++;
			var model = piece.Model;
			var source = model.GetVertices();
			var tris = model.GetIndices();
			if ( source is null || tris is null || source.Length == 0 || tris.Length < 3 ) continue;
			if ( piece.Enabled ) piece.Enabled = false;
			modelPieces++;
			pieces++;

			var local = hole.ToLocal( piece.WorldTransform );
			var map = new int[source.Length];
			for ( var i = 0; i < source.Length; i++ )
			{
				var p = local.PointToWorld( source[i].Position );
				var key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));
				if ( !lookup.TryGetValue( key, out var index ) )
				{
					index = positions.Count;
					positions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );
					lookup[key] = index;
				}
				map[i] = index;
			}
			for ( var i = 0; i + 2 < tris.Length; i += 3 )
			{
				int a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];
				if ( a == b || b == c || a == c ) continue;
				indices.Add( a ); indices.Add( b ); indices.Add( c );
			}
		}

		Pieces = pieces;
		Vertices = positions.Count;
		Triangles = indices.Count / 3;

		if ( !_collider.IsValid() )
		{
			_collider = Components.GetOrCreate<ModelCollider>( FindMode.EverythingInSelf );
			_collider.Flags |= ComponentFlags.NotSaved; // rebuilt on load, never written to the scene file
		}

		if ( Triangles == 0 )
		{
			_collider.Enabled = false;
			Log.Info( $"GolfHoleCollision: {GameObject.Name} has no collision: {found} pieces found, {Pieces} with collision data" );
			return;
		}

		_collider.Model = Model.Builder.AddCollisionMesh( positions, indices ).Create();
		_collider.Static = true;
		_collider.Surface = Surface.FindByName( SurfaceName ) ?? _collider.Surface;
		_collider.Enabled = true;

		Log.Info( $"GolfHoleCollision: {GameObject.Name} built {Triangles} triangles from {Pieces} of {found} pieces, {modelPieces} of them models ({Vertices} welded vertices) in {watch.ElapsedMilliseconds} ms" );
	}
}
mrtn.golfkit / Code/GolfRotator.cs
Game library
using Sandbox;
using System;
using System.Linq;

/// <summary>
/// Spins the object it sits on round one of its own axes at a steady rate, on every machine and
/// in the editor too (nothing is networked: every machine turns it the same way from the same
/// start, and a prop that is a little out of step between machines does not matter). Made for
/// the windmill's blades, whose axle is their local Y.
///
/// With a Rigidbody on the same object (motion off, so the body is keyframed in play) the turn
/// goes through the physics body with a matching angular velocity, and on the host the box
/// colliders on the object also push golf balls by hand: a ball inside the sweep is kept awake,
/// and a ball an arm box overlaps is given the arm's surface speed at the contact point, so
/// resting balls get swatted instead of being passed through.
/// </summary>
[Title( "Golf Rotator" ), Icon( "sync" ), Category( "Golf" )]
public sealed class GolfRotator : Component, Component.ExecuteInEditor
{
	/// <summary>The local axis to turn round. The windmill blades turn round their local Y.</summary>
	[Property]
	public Vector3 Axis { get; set; } = new Vector3( 0f, 1f, 0f );

	/// <summary>Turning speed in degrees per second; negative turns the other way.</summary>
	[Property, Title( "Degrees Per Second" ), Range( -360f, 360f )]
	public float DegreesPerSecond { get; set; } = 30f;

	Rigidbody _body;
	BoxCollider[] _arms = Array.Empty<BoxCollider>();
	float _reach;
	bool _loggedContact;

	protected override void OnStart()
	{
		_body = GetComponent<Rigidbody>( true );
		_arms = GetComponents<BoxCollider>( true ).ToArray();
		_reach = _arms.Length == 0 ? 0f : _arms.Max( a => a.Center.Length + a.Scale.Length * 0.5f );
	}

	protected override void OnUpdate()
	{
		// In play with a physics body the fixed step below does the turning.
		if ( !Scene.IsEditor && _body.IsValid() ) return;
		Turn( Time.Delta );
	}

	protected override void OnFixedUpdate()
	{
		if ( Scene.IsEditor || !_body.IsValid() ) return;
		var next = Turn( Time.Delta );
		if ( next is null ) return;

		// The keyframed body is rotated with the velocity that gets it there this step, so a ball
		// in the blade's path is pushed rather than finding the blade somewhere else next step.
		_body.SmoothRotate( next.Value, Time.Delta, Time.Delta );

		if ( Networking.IsHost ) PushBalls();
	}

	/// <summary>Turns the object by one step and returns the new world rotation, or null when there is nothing to do.</summary>
	Rotation? Turn( float seconds )
	{
		var axis = Axis;
		if ( axis.IsNearlyZero() || DegreesPerSecond == 0f || seconds <= 0f ) return null;
		LocalRotation *= Rotation.FromAxis( axis.Normal, DegreesPerSecond * seconds );
		return WorldRotation;
	}

	/// <summary>
	/// Host only. Every ball within the sweep is kept awake (a sleeping ball ignores a keyframed
	/// body sliding into it), and a ball that an arm box overlaps gets at least the arm's surface
	/// velocity along the contact normal and is lifted out of the box.
	/// </summary>
	void PushBalls()
	{
		if ( _arms.Length == 0 || DegreesPerSecond == 0f ) return;

		var hub = WorldPosition;
		var omega = WorldRotation * Axis.Normal * DegreesPerSecond.DegreeToRadian();
		var transform = WorldTransform;

		foreach ( var ball in Scene.GetAllComponents<IGolfCourseBall>() )
		{
			if ( !ball.GameObject.IsValid() || ball.IsHoled || !ball.Rigidbody.IsValid() ) continue;

			var pos = ball.WorldPosition;
			if ( pos.Distance( hub ) > _reach + ball.Radius + 4f ) continue;

			ball.Rigidbody.Sleeping = false;

			foreach ( var arm in _arms )
			{
				if ( !arm.IsValid() || !arm.Enabled ) continue;

				// Closest point of the arm box to the ball's centre, in the box's own frame.
				var local = transform.PointToLocal( pos ) - arm.Center;
				var half = arm.Scale * 0.5f;
				var clamped = new Vector3( Math.Clamp( local.x, -half.x, half.x ), Math.Clamp( local.y, -half.y, half.y ), Math.Clamp( local.z, -half.z, half.z ) );
				var closest = transform.PointToWorld( clamped + arm.Center );
				var gap = pos - closest;
				var dist = gap.Length;
				if ( dist > ball.Radius ) continue;

				var surface = Vector3.Cross( omega, closest - hub );
				var normal = dist > 0.05f ? gap.Normal : surface.Normal;
				if ( normal.IsNearlyZero() ) continue;

				// Match the arm's speed into the ball; a ball already moving away is left alone.
				var velocity = ball.Rigidbody.Velocity;
				var relative = (velocity - surface).Dot( normal );
				if ( relative < 0f ) ball.Rigidbody.Velocity = velocity - normal * relative;

				// Lift it out of the box so the next step starts from a clean contact.
				if ( dist < ball.Radius - 0.1f ) ball.WorldPosition = pos + normal * (ball.Radius - dist);

				if ( !_loggedContact )
				{
					_loggedContact = true;
					Log.Info( $"GolfRotator: {GameObject.Name} first touched {ball.OwnerName}'s ball at {closest}, arm surface speed {surface.Length:0}, ball {velocity.Length:0} -> {ball.Rigidbody.Velocity.Length:0} u/s" );
				}

				break;
			}
		}
	}
}
mrtn.golfkit / Code/GolfCourseInfo.cs
Game library
using Sandbox;
using System.Linq;

/// <summary>
/// What a course scene says about itself, on one object at the scene root. The game reads it:
/// the name, the ambience loop played while the scene is loaded, how far the sun's shadows reach
/// (0 leaves the engine's own distance), and which wall set the Kit Wall Builder picks.
/// </summary>
[Title( "Golf Course Info" ), Category( "Golf" ), Icon( "flag" )]
public sealed class GolfCourseInfo : Component
{
	/// <summary>The models the Kit Wall Builder tool places: the Blender kit (wall_100 and post_10) or the Minigolf hedge set.</summary>
	public enum KitWallStyle
	{
		Kit,
		Minigolf,
	}

	/// <summary>The course's name, as shown to the players.</summary>
	[Property, Title( "Course Name" )]
	public string CourseName { get; set; } = "";

	/// <summary>A 2D loop every machine plays while this scene is loaded. None for a silent course.</summary>
	[Property]
	public SoundEvent Ambience { get; set; }

	/// <summary>
	/// How far from the camera the sun's shadow cascades reach, in units, while this course is
	/// loaded. A small course gets crisp shadows from a short distance; 0 keeps the engine default.
	/// </summary>
	[Property, Title( "Shadow Distance" ), Range( 0f, 20000f )]
	public float ShadowDistance { get; set; } = 0f;

	/// <summary>Which wall models the Kit Wall Builder places on this course.</summary>
	[Property, Title( "Wall Style" )]
	public KitWallStyle WallStyle { get; set; } = KitWallStyle.Kit;

	/// <summary>The course info in a scene, or null when it has none.</summary>
	public static GolfCourseInfo Find( Scene scene )
	{
		return scene?.GetAllComponents<GolfCourseInfo>().FirstOrDefault();
	}
}
mrtn.golfkit / Code/GolfCupShape.cs
Game library
using Sandbox;
using System;

/// <summary>
/// The shape of a cup piece: Straight, Curved or Trench. Changing it in the Inspector sets both the Model
/// Renderer's and the Model Collider's model to the matching kit model, so one dropdown swaps the
/// look and the collision, and the hole's merged collider picks the new opening up. On enable it
/// follows whatever model the piece already has, so a model set by hand is left alone.
///
/// It also owns the cup's scoring trigger (the Box Collider on the same object): the trigger's
/// centre and size for the chosen shape are re-applied every frame, in the editor and in play, so
/// a change by hand (the Inspector's Set To GameObject Bounds, say) snaps straight back, and the
/// collider is hidden from the Inspector.
/// </summary>
[Title( "Golf Cup Shape" ), Icon( "sports_golf" )]
public sealed class GolfCupShape : Component, Component.ExecuteInEditor
{
	public enum CupShapeKind
	{
		Straight,
		Curved,
		Trench,
	}

	public const string StraightModel = "models/kit/cup_straight.vmdl";
	public const string CurvedModel = "models/kit/cup_curved.vmdl";
	public const string TrenchModel = "models/kit/trench/trench_hole.vmdl";

	/// <summary>Straight drops the ball straight into the cup; Curved has a bowl that funnels it in; Trench is the clay trench tile with a funnel.</summary>
	[Property, Title( "Cup Shape" )]
	public CupShapeKind Shape { get; set; } = CupShapeKind.Straight;

	CupShapeKind? _applied;

	protected override void OnEnabled()
	{
		// Start from the model the piece carries, so a model chosen by hand isn't overwritten.
		var current = Detect();
		if ( current.HasValue ) Shape = current.Value;
		_applied = Shape;
	}

	protected override void OnUpdate()
	{
		if ( _applied != Shape ) Apply();
		OwnTrigger();
	}

	/// <summary>The scoring trigger's centre and size for a shape: a small box at the bottom of the cup.</summary>
	public static (Vector3 Center, Vector3 Size) TriggerFor( CupShapeKind shape ) => shape switch
	{
		CupShapeKind.Straight => (new Vector3( 0f, 0f, -40f ), new Vector3( 8f, 8f, 14f )),
		_ => (new Vector3( 0f, 0f, -52f ), new Vector3( 8f, 8f, 14f )),   // the curved bowl and the trench funnel are 60 deep
	};

	/// <summary>Puts the trigger box where this shape wants it, keeps it a trigger, and keeps it out of the Inspector.</summary>
	void OwnTrigger()
	{
		var box = GetComponent<BoxCollider>( true );
		if ( box is null ) return;

		var (center, size) = TriggerFor( Shape );
		if ( box.Center != center ) box.Center = center;
		if ( box.Scale != size ) box.Scale = size;
		if ( !box.IsTrigger ) box.IsTrigger = true;

		const ComponentFlags owned = ComponentFlags.Hidden | ComponentFlags.NotEditable;
		if ( (box.Flags & owned) != owned ) box.Flags |= owned;
	}

	CupShapeKind? Detect()
	{
		var name = GetComponent<ModelRenderer>( true )?.Model?.Name;
		if ( string.IsNullOrEmpty( name ) ) return null;
		if ( name.Contains( "trench", StringComparison.OrdinalIgnoreCase ) ) return CupShapeKind.Trench;
		return name.Contains( "curved", StringComparison.OrdinalIgnoreCase ) ? CupShapeKind.Curved : CupShapeKind.Straight;
	}

	void Apply()
	{
		var model = Model.Load( Shape switch { CupShapeKind.Curved => CurvedModel, CupShapeKind.Trench => TrenchModel, _ => StraightModel } );
		if ( model is null ) return;

		var renderer = GetComponent<ModelRenderer>( true );
		if ( renderer is not null )
		{
			renderer.Model = model;
			// The trench tile wears its own clay; the kit cups' grass override would hide it.
			if ( Shape == CupShapeKind.Trench ) renderer.MaterialOverride = null;
		}
		var collider = GetComponent<ModelCollider>( true );
		if ( collider is not null ) collider.Model = model;

		_applied = Shape;
		GameObject.GetComponentInParent<GolfHoleCollision>( true )?.MarkDirty();
	}
}
mrtn.golfkit / .obj/__compiler_extra.cs
Game library
global using static Sandbox.Internal.GlobalGameNamespace;
global using Microsoft.AspNetCore.Components;
global using Microsoft.AspNetCore.Components.Rendering;
[assembly: global::System.Reflection.AssemblyMetadata( "AddonTitle", "Golf With Your Guns Kit" )]
[assembly: global::System.Reflection.AssemblyMetadata( "AddonIdent", "golfkit" )]
[assembly: global::System.Reflection.AssemblyMetadata( "OrgIdent", "mrtn" )]
[assembly: global::System.Reflection.AssemblyMetadata( "Ident", "mrtn.golfkit" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineVersion", "30" )]
[assembly: global::System.Reflection.AssemblyMetadata( "EngineMinorVersion", "1" )]

[assembly: System.Runtime.Versioning.TargetFramework( ".NETCoreApp,Version=v9.0", FrameworkDisplayName = ".NET 9.0" )]
[assembly: global::System.Reflection.AssemblyMetadata( "CompileTime", "2026-10-17T20:21:46.3689581Z" )]
[assembly: global::System.Reflection.AssemblyVersion("0.0.110.0")]
[assembly: global::System.Reflection.AssemblyFileVersion("0.0.110.0")]
mrtn.golfkit / Code/GolfAudioZone.cs
Game library
using Sandbox;
using Sandbox.Audio;
using System;
using System.Collections.Generic;

/// <summary>
/// An underground audio zone: a box (the Box Collider on the same object, Is Trigger on, used only
/// for its size) that muffles the world while the local player's camera is inside it. The music,
/// ambience, golf sound and shelling mixers (<see cref="GolfMixers"/>) each get the engine's
/// low-pass processor and a volume drop, faded in over Fade Time on entering and out again on
/// leaving. The guns play on the default mixer and are untouched. Where zones overlap, the
/// strongest wins per mixer: the lowest cutoff and the biggest drop among the zones the camera is
/// in. The camera is tested against the box directly every frame, so it works for every camera,
/// the player view, the fly-over, the free camera and the menu orbit; nothing is networked.
///
/// The engine's low-pass is a one-pole filter whose Cutoff is the per-sample smoothing factor,
/// not a frequency, so Cutoff Hz is converted: alpha = 1 - exp( -2 pi f / sample rate ). A factor
/// of 1 passes the sound through unchanged, which is what the fade starts from and returns to.
/// Drawn in the editor as a translucent blue box.
/// </summary>
[Title( "Golf Audio Zone" ), Category( "Golf" ), Icon( "hearing" )]
public sealed class GolfAudioZone : Component
{
	/// <summary>Low-pass cutoff inside the zone, Hz. Lower is more muffled.</summary>
	[Property, Group( "Filter" ), Range( 50f, 8000f ), Title( "Cutoff Hz" )]
	public float CutoffHz { get; set; } = 600f;

	/// <summary>Seconds the filter and the drop take to fade in or out.</summary>
	[Property, Group( "Filter" ), Range( 0f, 5f ), Title( "Fade Time" )]
	public float FadeTime { get; set; } = 0.6f;

	/// <summary>How much of the volume is taken away inside, 0 to 1; 0.2 leaves 80 percent.</summary>
	[Property, Group( "Filter" ), Range( 0f, 1f ), Title( "Volume Drop" )]
	public float VolumeDrop { get; set; } = 0.2f;

	[Property, Group( "Mixers" ), Title( "Music" )]
	public bool AffectMusic { get; set; } = true;

	[Property, Group( "Mixers" ), Title( "Ambience" )]
	public bool AffectAmbience { get; set; } = true;

	[Property, Group( "Mixers" ), Title( "Golf Sounds" )]
	public bool AffectSounds { get; set; } = true;

	[Property, Group( "Mixers" ), Title( "Shelling" )]
	public bool AffectShelling { get; set; } = true;

	// Every enabled zone; the first one drives the mixers for all of them.
	static readonly List<GolfAudioZone> Zones = new();

	// Per mixer: the processor, the current smoothing factor (1 = untouched) and volume (1 = full).
	sealed class Channel
	{
		public string Name;
		public LowPassProcessor Filter;
		public Mixer Mixer;
		public float Alpha = 1f;
		public float Volume = 1f;
		// Zones muffle this mixer; the default "Game" mixer (guns, footsteps) is only muffled by an outside request.
		public bool ZoneControlled = true;
	}

	static readonly Channel[] Channels =
	{
		new() { Name = GolfMixers.MusicName },
		new() { Name = GolfMixers.AmbienceName },
		new() { Name = GolfMixers.SoundsName },
		new() { Name = GolfMixers.ShellingName },
		new() { Name = "Game", ZoneControlled = false },
	};

	// An outside request to muffle everything (the distraction hit): cutoff factor, volume and how
	// strong it is right now, 0 to 1. Made fresh every frame by the requester; it lapses by itself.
	static float _muffleAlpha = 1f, _muffleVolume = 1f, _muffleStrength;
	static float _muffleFrame = -1f;

	/// <summary>
	/// Muffle every mixer, the guns included, this frame: a low-pass at this cutoff and a volume
	/// drop, scaled by strength (0 none, 1 full). Call it every frame it should apply; the caller
	/// shapes the fade in and out through the strength. Overlapping audio zones still take the strongest.
	/// </summary>
	public static void SetMuffle( float cutoffHz, float volumeDrop, float strength )
	{
		_muffleAlpha = AlphaFor( cutoffHz );
		_muffleVolume = 1f - Math.Clamp( volumeDrop, 0f, 1f );
		_muffleStrength = Math.Clamp( strength, 0f, 1f );
		_muffleFrame = Time.Now;
	}

	/// <summary>
	/// Runs the mixer update when no zone is there to do it, so an outside muffle request works in
	/// scenes without audio zones. A zone that exists drives the mixers itself.
	/// </summary>
	public static void TickWithoutZones()
	{
		if ( Zones.Count > 0 ) return;
		Tick();
	}

	protected override void OnEnabled()
	{
		if ( !Zones.Contains( this ) ) Zones.Add( this );
	}

	protected override void OnDisabled()
	{
		Zones.Remove( this );
		if ( Zones.Count == 0 ) ResetMixers();
	}

	/// <summary>True when a world point is inside this zone's box.</summary>
	public bool Contains( Vector3 worldPosition )
	{
		var local = WorldTransform.PointToLocal( worldPosition );
		return Box().Contains( local );
	}

	bool Affects( int channel ) => channel switch { 0 => AffectMusic, 1 => AffectAmbience, 2 => AffectSounds, _ => AffectShelling };

	BBox Box()
	{
		var box = GetComponent<BoxCollider>( true );
		return box is not null
			? BBox.FromPositionAndSize( box.Center, box.Scale )
			: BBox.FromPositionAndSize( new Vector3( 0f, 0f, 200f ), new Vector3( 400f, 400f, 400f ) );
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor ) return;
		// One zone does the work for all of them, so overlapping zones blend into one answer.
		if ( Zones.Count == 0 || Zones[0] != this ) return;
		Tick();
	}

	/// <summary>
	/// One update of every mixer: the strongest zone the camera is in per mixer, fading at that
	/// zone's Fade Time, and the outside muffle request on top, which follows its own strength at once.
	/// </summary>
	static void Tick()
	{
		var scene = Game.ActiveScene;
		var camera = scene?.Camera;
		var position = camera.IsValid() ? camera.WorldPosition : Vector3.Zero;
		var muffling = _muffleFrame >= Time.Now - 0.1f && _muffleStrength > 0f;

		for ( var i = 0; i < Channels.Length; i++ )
		{
			var channel = Channels[i];

			// The strongest zone the camera is in, per mixer.
			var targetAlpha = 1f;
			var targetVolume = 1f;
			var fade = 0.6f;
			if ( channel.ZoneControlled )
			{
				foreach ( var zone in Zones )
				{
					if ( !zone.IsValid() || !zone.Affects( i ) ) continue;
					if ( !camera.IsValid() || !zone.Contains( position ) ) continue;
					var alpha = AlphaFor( zone.CutoffHz );
					if ( alpha < targetAlpha ) { targetAlpha = alpha; fade = zone.FadeTime; }
					targetVolume = Math.Min( targetVolume, 1f - Math.Clamp( zone.VolumeDrop, 0f, 1f ) );
				}
			}

			// The outside request wins where it is stronger, and is followed at once: its caller shapes the fade.
			var instant = false;
			if ( muffling )
			{
				var alpha = MathX.Lerp( 1f, _muffleAlpha, _muffleStrength );
				var volume = MathX.Lerp( 1f, _muffleVolume, _muffleStrength );
				if ( alpha < targetAlpha ) { targetAlpha = alpha; instant = true; }
				if ( volume < targetVolume ) { targetVolume = volume; instant = true; }
			}
			else if ( !channel.ZoneControlled || Zones.Count == 0 )
			{
				instant = true;   // nothing asks for anything: straight back to clean
			}

			var step = instant || fade <= 0f ? 1f : Math.Clamp( Time.Delta / fade, 0f, 1f );
			// Linear in the smoothing factor sounds right: it sweeps the muffling smoothly.
			channel.Alpha = MathX.Lerp( channel.Alpha, targetAlpha, step );
			channel.Volume = MathX.Lerp( channel.Volume, targetVolume, step );
			if ( Math.Abs( channel.Alpha - targetAlpha ) < 0.002f ) channel.Alpha = targetAlpha;
			if ( Math.Abs( channel.Volume - targetVolume ) < 0.002f ) channel.Volume = targetVolume;
			Apply( channel );
		}
	}

	/// <summary>The mixer's sample rate; the engine's own figure is not exposed to game code.</summary>
	const float SampleRate = 44100f;

	/// <summary>The one-pole smoothing factor that gives this cutoff at the mix sample rate.</summary>
	static float AlphaFor( float hz )
	{
		return Math.Clamp( 1f - MathF.Exp( -2f * MathF.PI * Math.Max( 10f, hz ) / SampleRate ), 0.001f, 1f );
	}

	static void Apply( Channel channel )
	{
		if ( channel.Mixer is null )
		{
			channel.Mixer = GolfMixers.Get( channel.Name );
			if ( channel.Mixer is null ) return;
		}

		var untouched = channel.Alpha >= 0.999f;
		if ( channel.Filter is null )
		{
			if ( untouched ) { channel.Mixer.Volume = channel.Volume; return; }
			channel.Filter = channel.Mixer.GetProcessor<LowPassProcessor>();
			if ( channel.Filter is null )
			{
				channel.Filter = new LowPassProcessor { Cutoff = 1f };
				channel.Mixer.AddProcessor( channel.Filter );
			}
		}

		channel.Filter.Cutoff = channel.Alpha;
		channel.Filter.Enabled = !untouched;
		channel.Mixer.Volume = channel.Volume;
	}

	/// <summary>No zones left (scene change, all disabled): every mixer back to clean and full.</summary>
	static void ResetMixers()
	{
		foreach ( var channel in Channels )
		{
			channel.Alpha = 1f;
			channel.Volume = 1f;
			if ( channel.Mixer is null ) continue;
			if ( channel.Filter is not null )
			{
				channel.Filter.Cutoff = 1f;
				channel.Filter.Enabled = false;
			}
			channel.Mixer.Volume = 1f;
		}
	}

	protected override void DrawGizmos()
	{
		var bounds = Box();
		var alpha = Gizmo.IsSelected ? 0.35f : (Gizmo.IsHovered ? 0.25f : 0.12f);
		var blue = new Color( 0.25f, 0.55f, 1f );
		Gizmo.Draw.Color = blue.WithAlpha( alpha );
		Gizmo.Draw.SolidBox( bounds );
		Gizmo.Draw.LineThickness = Gizmo.IsSelected ? 2f : 1f;
		Gizmo.Draw.Color = blue.WithAlpha( Gizmo.IsSelected ? 0.9f : 0.5f );
		Gizmo.Draw.LineBBox( bounds );
	}
}
mrtn.golfkit / Code/GolfMenuCamera.cs
Game library
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 );
		}
	}
}
mrtn.golfkit / Code/MudWaterRipple.cs
Game library
using Sandbox;
using System;
using System.Linq;

/// <summary>
/// Ripples on a mud water plane. Every Interval (a random time between the min and max) a
/// shockwave starts at a random point on the plane: a flat overlay quad carrying a ring texture
/// (three concentric rings in its alpha and normal map, from Tools/make_mud_water.py) is placed on
/// the surface, scaled up at Ring Speed until it reaches Max Radius, and fades to nothing on the
/// way, about two seconds at the defaults. The overlay's fade is the renderer's Tint alpha; the
/// complex shader has no attribute to drive a ripple inside the water material itself, so the
/// overlay is the cheap route in this build. Nothing is networked, every machine ripples on its
/// own; a small pool of overlays, no allocation per ripple. Put it on the object that renders the
/// water plane: the plane's extent is that renderer's model bounds.
///
/// Between ripples the water also sways in place: the plane's material is copied at start and the
/// copy's texture offset is moved round a tiny circle every frame (the complex shader has no
/// wobble or animated normal option, only a straight scroll, so this is done from code). Nothing
/// flows, the texture only drifts back and forth by a fraction of a texel.
/// </summary>
[Title( "Mud Water Ripple" ), Category( "Golf" ), Icon( "water" )]
public sealed class MudWaterRipple : Component
{
	/// <summary>Shortest wait between ripples, seconds.</summary>
	[Property, Range( 0.5f, 60f ), Title( "Interval Min" )]
	public float IntervalMin { get; set; } = 5f;

	/// <summary>Longest wait between ripples, seconds.</summary>
	[Property, Range( 0.5f, 60f ), Title( "Interval Max" )]
	public float IntervalMax { get; set; } = 15f;

	/// <summary>How fast the rings spread, units per second.</summary>
	[Property, Range( 10f, 1000f ), Title( "Ring Speed" )]
	public float RingSpeed { get; set; } = 100f;

	/// <summary>How visible the rings are when they start, 0 to 1.</summary>
	[Property, Range( 0f, 1f ), Title( "Ring Strength" )]
	public float RingStrength { get; set; } = 0.8f;

	/// <summary>Radius the rings reach before they are gone, units.</summary>
	[Property, Range( 20f, 2000f ), Title( "Max Radius" )]
	public float MaxRadius { get; set; } = 200f;

	/// <summary>Ripples in flight at once; the pool size.</summary>
	[Property, Range( 1, 8 )]
	public int MaxRipples { get; set; } = 3;

	/// <summary>Sway the water texture in place between ripples.</summary>
	[Property, Group( "Wobble" )]
	public bool Wobble { get; set; } = true;

	/// <summary>How far the texture sways, in UV units; 0.002 is a fraction of a texel.</summary>
	[Property, Group( "Wobble" ), Range( 0f, 0.02f ), Title( "Wobble Radius" )]
	public float WobbleRadius { get; set; } = 0.002f;

	/// <summary>Seconds for one full sway round the circle.</summary>
	[Property, Group( "Wobble" ), Range( 1f, 30f ), Title( "Wobble Period" )]
	public float WobblePeriod { get; set; } = 6f;

	const string QuadModel = "models/mud_water/ripple_quad.vmdl";
	const string RingMaterial = "textures/mud_water/ripple_ring.vmat";
	const float Lift = 0.75f;   // the overlay floats this far above the water so it never z-fights

	ModelRenderer[] _overlays = Array.Empty<ModelRenderer>();
	float[] _born = Array.Empty<float>();
	int _next;
	float _nextAt;
	ModelRenderer _plane;
	Material _planeMaterial;   // what the plane had before the wobble copy, put back on disable
	Material _wobble;

	protected override void OnStart()
	{
		var model = Model.Load( QuadModel );
		var material = Material.Load( RingMaterial );
		if ( model is null || material is null )
		{
			Log.Warning( $"MudWaterRipple: missing {QuadModel} or {RingMaterial}" );
			return;
		}

		var count = Math.Clamp( MaxRipples, 1, 8 );
		_overlays = new ModelRenderer[count];
		_born = new float[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = new GameObject( GameObject, false, $"ripple {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var renderer = go.AddComponent<ModelRenderer>();
			renderer.Model = model;
			renderer.MaterialOverride = material;
			_overlays[i] = renderer;
		}

		_nextAt = Time.Now + Wait();

		// The wobble works on a private copy of the plane's material, so the asset itself is untouched.
		_plane = GetComponent<ModelRenderer>();
		var source = _plane?.MaterialOverride ?? _plane?.Model?.Materials.FirstOrDefault();
		if ( Wobble && source is not null )
		{
			_planeMaterial = _plane.MaterialOverride;
			_wobble = source.CreateCopy( "mud water wobble" );
			_plane.MaterialOverride = _wobble;
		}
	}

	protected override void OnDisabled()
	{
		foreach ( var overlay in _overlays )
			if ( overlay.IsValid() ) overlay.GameObject.Enabled = false;

		if ( _wobble is not null && _plane.IsValid() && _plane.MaterialOverride == _wobble )
			_plane.MaterialOverride = _planeMaterial;
		_wobble = null;
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor || _overlays.Length == 0 ) return;

		var now = Time.Now;

		if ( _wobble is not null )
		{
			var angle = now / Math.Max( 0.1f, WobblePeriod ) * MathF.PI * 2f;
			var radius = Wobble ? WobbleRadius : 0f;
			_wobble.Set( "g_vTexCoordOffset", new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * radius );
		}
		var life = MaxRadius / Math.Max( 1f, RingSpeed );

		for ( var i = 0; i < _overlays.Length; i++ )
		{
			var overlay = _overlays[i];
			if ( !overlay.IsValid() || !overlay.GameObject.Enabled ) continue;

			var age = now - _born[i];
			if ( age >= life )
			{
				overlay.GameObject.Enabled = false;
				continue;
			}

			var t = age / life;
			var radius = Math.Max( 1f, RingSpeed * age );
			overlay.GameObject.WorldScale = new Vector3( radius, radius, 1f );
			overlay.Tint = Color.White.WithAlpha( RingStrength * (1f - t) * (1f - t) );
		}

		if ( now < _nextAt ) return;
		_nextAt = now + Wait();
		Spawn();
	}

	float Wait()
	{
		var min = Math.Min( IntervalMin, IntervalMax );
		var max = Math.Max( IntervalMin, IntervalMax );
		return Game.Random.Float( min, max );
	}

	/// <summary>A ripple at a random point on the plane's top surface.</summary>
	void Spawn()
	{
		var i = _next;
		_next = (_next + 1) % _overlays.Length;
		var overlay = _overlays[i];
		if ( !overlay.IsValid() ) return;

		var plane = GetComponent<ModelRenderer>();
		var bounds = plane?.Model?.Bounds ?? new BBox( new Vector3( -100f, -100f, 0f ), new Vector3( 100f, 100f, 0f ) );
		var local = new Vector3(
			Game.Random.Float( bounds.Mins.x, bounds.Maxs.x ),
			Game.Random.Float( bounds.Mins.y, bounds.Maxs.y ),
			bounds.Maxs.z );
		var position = WorldTransform.PointToWorld( local ) + Vector3.Up * Lift;

		overlay.GameObject.WorldPosition = position;
		overlay.GameObject.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );
		overlay.GameObject.WorldScale = new Vector3( 1f, 1f, 1f );
		overlay.Tint = Color.White.WithAlpha( RingStrength );
		_born[i] = Time.Now;
		overlay.GameObject.Enabled = true;
	}
}
mrtn.golfkit / Code/TrenchShelling.cs
Game library
using Sandbox;
using System;

/// <summary>
/// Distant artillery for the trench map. Every Interval (a fresh random wait between the min and
/// max, read from the Inspector each time a shell goes off) the host picks a shell: usually far
/// off, a random yaw and a distance in the Far range; sometimes (Near Chance) a near one in the
/// Near range. The host tells every machine the yaw, the distance and the kind, and each machine
/// places the shell at that yaw and distance from its own camera, so every player gets the same
/// near or far shell wherever they stand: an explosion sound in 3D at that spot (the far event is
/// pitched down and softened, the near one is as it comes) and a camera shake sized by the
/// distance, a soft rumble of about a second for a far one, a hard kick and a rolling two second
/// decay plus a low rumble layer for a near one. No damage, nothing happens to balls, no particles.
/// Runs from scene load, so the menu and the lobby hear it too. Place one anywhere; its position
/// does not matter, and only one instance keeps the clock.
///
/// The shake is smooth noise, new every frame: layered sines on position (up to the shake
/// strength in units) and on pitch, yaw and roll (up to Shake Angle, scaled by the strength), with
/// a fast attack and an exponential decay. It reaches whichever camera is active. The engine
/// composes each camera's view once per tick after Update, through the camera modifiers, and
/// the shake is one of those (after the fly-over and the free camera). But the fly-over, the
/// free camera and the menu orbit write the camera's transform in PreRender, after that
/// composition, and the engine then renders from the raw transform and drops the composed view.
/// So at the very end of the tick (the FinishUpdate stage, after every PreRender) the
/// <see cref="TrenchShellingSystem"/> checks whether the active camera's transform changed after
/// composition and, if it did, adds the same offset to the transform itself, undoing it again at
/// the start of the next tick so nothing accumulates.
/// </summary>
[Title( "Trench Shelling" ), Category( "Golf" ), Icon( "flare" )]
public sealed class TrenchShelling : Component, ICameraModifier
{
	/// <summary>The instance that keeps the clock and shakes the camera; the first enabled one.</summary>
	public static TrenchShelling Current { get; private set; }

	/// <summary>Shortest wait between shells, seconds.</summary>
	[Property, Group( "Timing" ), Range( 1f, 300f ), Title( "Interval Min" )]
	public float IntervalMin { get; set; } = 20f;

	/// <summary>Longest wait between shells, seconds.</summary>
	[Property, Group( "Timing" ), Range( 1f, 300f ), Title( "Interval Max" )]
	public float IntervalMax { get; set; } = 60f;

	/// <summary>Closest a far shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 500f, 20000f ), Title( "Far Distance Min" )]
	public float FarMin { get; set; } = 2000f;

	/// <summary>Furthest a far shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 500f, 20000f ), Title( "Far Distance Max" )]
	public float FarMax { get; set; } = 6000f;

	/// <summary>Chance a shell is a near one, 0 to 1.</summary>
	[Property, Group( "Distance" ), Range( 0f, 1f ), Title( "Near Chance" )]
	public float NearChance { get; set; } = 0.15f;

	/// <summary>Closest a near shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 50f, 5000f ), Title( "Near Distance Min" )]
	public float NearMin { get; set; } = 300f;

	/// <summary>Furthest a near shell lands, units from each player's camera.</summary>
	[Property, Group( "Distance" ), Range( 50f, 5000f ), Title( "Near Distance Max" )]
	public float NearMax { get; set; } = 800f;

	/// <summary>The far shell: pitched down and softened. Empty uses sounds/trench/shell_far.</summary>
	[Property, Group( "Sound" ), Title( "Far Sound" )]
	public SoundEvent FarSound { get; set; }

	/// <summary>The near shell. Empty uses sounds/trench/shell_near.</summary>
	[Property, Group( "Sound" ), Title( "Near Sound" )]
	public SoundEvent NearSound { get; set; }

	/// <summary>Low rumble layered under a near shell. Empty uses sounds/trench/shell_rumble; None for none.</summary>
	[Property, Group( "Sound" ), Title( "Rumble Sound" )]
	public SoundEvent RumbleSound { get; set; }

	/// <summary>Volume of every shell sound, 0 to 2.</summary>
	[Property, Group( "Sound" ), Range( 0f, 2f )]
	public float Volume { get; set; } = 1f;

	/// <summary>Shake of a far shell for a camera right next to it, world units.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Far Shake" )]
	public float FarShake { get; set; } = 1.2f;

	/// <summary>How long a far shell rumbles, seconds.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Far Shake Time" )]
	public float FarShakeTime { get; set; } = 1f;

	/// <summary>Shake of a near shell for a camera right next to it, world units.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Near Shake" )]
	public float NearShake { get; set; } = 6f;

	/// <summary>How long a near shell shakes, seconds.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Near Shake Time" )]
	public float NearShakeTime { get; set; } = 2f;

	/// <summary>Speed of the shake noise, wobbles per second.</summary>
	[Property, Group( "Shake" ), Range( 1f, 60f ), Title( "Shake Frequency" )]
	public float ShakeFrequency { get; set; } = 9f;

	/// <summary>Tilt of the view at full Near Shake strength, degrees; smaller shakes tilt less in proportion.</summary>
	[Property, Group( "Shake" ), Range( 0f, 10f ), Title( "Shake Angle" )]
	public float ShakeAngle { get; set; } = 2f;

	/// <summary>Distance from the camera at which a shell's shake has faded to nothing, units.</summary>
	[Property, Group( "Shake" ), Range( 500f, 30000f ), Title( "Shake Range" )]
	public float ShakeRange { get; set; } = 9000f;

	const string FarSoundPath = "sounds/trench/shell_far.sound";
	const string NearSoundPath = "sounds/trench/shell_near.sound";
	const string RumbleSoundPath = "sounds/trench/shell_rumble.sound";

	// The host's clock. A TimeUntil survives a hotload and counts in scene time.
	TimeUntil _nextShell;
	bool _scheduled;

	// The shake in progress.
	float _amplitude;        // units of position shake at the start
	float _started;          // Time.Now it began
	float _duration;
	bool _near;
	Vector3 _kickDirection;  // the near shell's first hard knock
	int _shell;              // counts shells, so the camera line is logged once each
	int _loggedShell;

	// The camera the view was composed for this tick, and its raw transform at that moment.
	CameraComponent _composedCamera;
	Vector3 _composedPosition;
	Rotation _composedRotation;
	bool _composedThisTick;

	// An offset written into a camera's transform at the end of a tick, taken out again at the start of the next.
	CameraComponent _rawCamera;
	Vector3 _rawOffset;
	Rotation _rawTilt = Rotation.Identity;
	bool _rawApplied;

	protected override void OnEnabled()
	{
		if ( !Current.IsValid() || Current.Scene != Scene ) Current = this;
	}

	protected override void OnDisabled()
	{
		RestoreEarly();
		if ( Current == this ) Current = null;
	}

	protected override void OnStart()
	{
		FarSound ??= ResourceLibrary.Get<SoundEvent>( FarSoundPath );
		NearSound ??= ResourceLibrary.Get<SoundEvent>( NearSoundPath );
		RumbleSound ??= ResourceLibrary.Get<SoundEvent>( RumbleSoundPath );
	}

	protected override void OnUpdate()
	{
		// One instance keeps the clock, on the host; everyone else only hears what it sends.
		if ( Scene.IsEditor || Current != this || !Networking.IsHost ) return;

		if ( !_scheduled )
		{
			Schedule();
			return;
		}

		if ( _nextShell > 0f ) return;
		Schedule();

		var near = Game.Random.Float( 0f, 1f ) < NearChance;
		var distance = near
			? Game.Random.Float( Math.Min( NearMin, NearMax ), Math.Max( NearMin, NearMax ) )
			: Game.Random.Float( Math.Min( FarMin, FarMax ), Math.Max( FarMin, FarMax ) );
		var yaw = Game.Random.Float( 0f, 360f );

		ShellRpc( yaw, distance, near );
	}

	/// <summary>A fresh random wait, from the Inspector values as they are right now.</summary>
	void Schedule()
	{
		var min = Math.Min( IntervalMin, IntervalMax );
		var max = Math.Max( IntervalMin, IntervalMax );
		var wait = Game.Random.Float( min, max );
		_nextShell = wait;
		_scheduled = true;
		Log.Info( $"TrenchShelling: next shell in {wait:0} s (interval {min:0} to {max:0})" );
	}

	/// <summary>A shell lands at this yaw and distance from this machine's own camera: the sound there and a shake sized by the distance.</summary>
	[Rpc.Broadcast]
	void ShellRpc( float yaw, float distance, bool near )
	{
		var camera = Scene.Camera;
		var origin = camera.IsValid() ? camera.WorldPosition : WorldPosition;
		var position = origin + Rotation.FromYaw( yaw ).Forward * distance;

		var sound = near ? NearSound : FarSound;
		if ( sound is not null && Volume > 0f )
		{
			var handle = Sound.Play( sound, position );
			if ( handle.IsValid() )
			{
				handle.Volume *= Volume;
				handle.TargetMixer = GolfMixers.Shelling ?? handle.TargetMixer;
			}
		}

		if ( near && RumbleSound is not null && Volume > 0f )
		{
			var rumble = Sound.Play( RumbleSound, position );
			if ( rumble.IsValid() )
			{
				rumble.Volume *= Volume;
				rumble.TargetMixer = GolfMixers.Shelling ?? rumble.TargetMixer;
			}
		}

		var falloff = 1f - Math.Clamp( distance / Math.Max( 1f, ShakeRange ), 0f, 1f );
		var amplitude = (near ? NearShake : FarShake) * falloff;
		var duration = near ? NearShakeTime : FarShakeTime;
		Log.Info( $"TrenchShelling: {( near ? "near" : "far" )} shell at {distance:0} units, yaw {yaw:0}, shake {amplitude:0.00} for {duration:0.0} s" );
		if ( amplitude <= 0.01f || duration <= 0f ) return;

		// The shake lives on the one instance that applies it. A stronger shell replaces a weaker
		// shake in progress; a weaker one is dropped while a stronger one still runs.
		var target = Current.IsValid() && Current.Scene == Scene ? Current : this;
		target.StartShake( amplitude, duration, near );
	}

	void StartShake( float amplitude, float duration, bool near )
	{
		if ( Remaining > 0f && _amplitude * Envelope( Time.Now - _started ) > amplitude ) return;

		_amplitude = amplitude;
		_duration = duration;
		_started = Time.Now;
		_near = near;
		_kickDirection = (Vector3.Random.WithZ( 0f ).Normal * 0.7f + Vector3.Down * 0.7f).Normal;
		_shell++;
	}

	/// <summary>Seconds of the current shake still to run, 0 when there is none.</summary>
	float Remaining => _duration <= 0f ? 0f : Math.Max( 0f, _duration - (Time.Now - _started) );

	/// <summary>
	/// Strength over the life of a shake, 0 to 1: a fast attack, then an exponential decay that is
	/// nearly gone by the end. Near shells hit at once and roll off; far ones swell in and fade.
	/// </summary>
	float Envelope( float age )
	{
		if ( age < 0f || _duration <= 0f || age >= _duration ) return 0f;
		var attack = _near ? 0.04f : 0.2f;
		var tau = _duration / 4f;
		if ( age < attack ) return age / attack;
		return MathF.Exp( -(age - attack) / tau );
	}

	/// <summary>Three layered sines with unrelated rates: smooth, never repeating within a shake, no steps.</summary>
	static float Noise( float t, float seed )
	{
		return MathF.Sin( t + seed ) * 0.55f + MathF.Sin( t * 1.83f + seed * 1.7f + 0.9f ) * 0.3f + MathF.Sin( t * 3.11f + seed * 2.3f + 2.1f ) * 0.15f;
	}

	/// <summary>The shake right now: a position offset in units and a tilt of the view.</summary>
	bool CurrentOffset( out Vector3 offset, out Rotation tilt )
	{
		offset = Vector3.Zero;
		tilt = Rotation.Identity;
		if ( Remaining <= 0f ) return false;

		var age = Time.Now - _started;
		var strength = _amplitude * Envelope( age );
		var t = Time.Now * ShakeFrequency * MathF.PI * 2f;

		offset = new Vector3( Noise( t, 1f ), Noise( t, 2f ), Noise( t, 3f ) ) * strength;

		// The near shell's hard first knock, gone in a quarter of a second.
		if ( _near )
			offset += _kickDirection * _amplitude * MathF.Exp( -age / 0.12f );

		var degrees = ShakeAngle * strength / Math.Max( 0.01f, NearShake );
		tilt = new Angles( Noise( t, 4f ) * degrees, Noise( t, 5f ) * degrees, Noise( t, 6f ) * degrees * 0.5f ).ToRotation();
		return true;
	}

	// ------------------------------------------------------------ reaching the active camera

	// After the fly-over (100) and the free camera (200), so the offset lands on the finished view.
	int ICameraModifier.CameraOrder => 900;

	void ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )
	{
		if ( Current != this ) return;
		if ( !camera.IsValid() ) return;

		// Remember what the camera's own transform was when its view was composed.
		_composedCamera = camera;
		_composedPosition = camera.WorldPosition;
		_composedRotation = camera.WorldRotation;
		_composedThisTick = true;

		if ( !CurrentOffset( out var offset, out var tilt ) ) return;
		view.Position += offset;
		view.Rotation *= tilt;
	}

	/// <summary>
	/// End of the tick, after every PreRender: if the active camera's transform was rewritten
	/// after its view was composed (the fly-over, the free camera, the menu orbit), the engine
	/// renders from that transform, so the shake goes into the transform too.
	/// </summary>
	public void ApplyLate()
	{
		var composed = _composedThisTick;
		_composedThisTick = false;

		var camera = composed && _composedCamera.IsValid() ? _composedCamera : Scene.Camera;
		if ( !camera.IsValid() ) return;
		if ( !CurrentOffset( out var offset, out var tilt ) ) return;

		var untouched = composed && camera == _composedCamera
			&& camera.WorldPosition == _composedPosition && camera.WorldRotation == _composedRotation;

		if ( untouched )
		{
			if ( _loggedShell != _shell )
			{
				_loggedShell = _shell;
				Log.Info( $"TrenchShelling: shake on camera '{camera.GameObject.Name}' through its composed view" );
			}
			return;
		}

		camera.WorldPosition += offset;
		camera.WorldRotation *= tilt;
		_rawCamera = camera;
		_rawOffset = offset;
		_rawTilt = tilt;
		_rawApplied = true;

		if ( _loggedShell != _shell )
		{
			_loggedShell = _shell;
			Log.Info( $"TrenchShelling: shake on camera '{camera.GameObject.Name}' through its transform (rewritten after composition)" );
		}
	}

	/// <summary>Start of the next tick: take last tick's offset back out of the transform, so nothing accumulates.</summary>
	public void RestoreEarly()
	{
		if ( !_rawApplied ) return;
		_rawApplied = false;
		if ( !_rawCamera.IsValid() ) return;

		_rawCamera.WorldPosition -= _rawOffset;
		_rawCamera.WorldRotation *= _rawTilt.Inverse;
	}
}

/// <summary>
/// Runs the shelling shake at the two ends of the tick: takes last tick's transform offset out at
/// StartUpdate, before anything else, and puts this tick's in at FinishUpdate, after every
/// component's PreRender, which is the last point before the frame renders.
/// </summary>
public sealed class TrenchShellingSystem : GameObjectSystem<TrenchShellingSystem>
{
	public TrenchShellingSystem( Scene scene ) : base( scene )
	{
		Listen( Stage.StartUpdate, -1000, RestoreEarly, "TrenchShelling.RestoreEarly" );
		Listen( Stage.FinishUpdate, 1000, ApplyLate, "TrenchShelling.ApplyLate" );
	}

	void RestoreEarly()
	{
		var shelling = TrenchShelling.Current;
		if ( shelling.IsValid() && shelling.Scene == Scene ) shelling.RestoreEarly();
	}

	void ApplyLate()
	{
		var shelling = TrenchShelling.Current;
		if ( shelling.IsValid() && shelling.Scene == Scene ) shelling.ApplyLate();
	}
}
mrtn.golfkit / KitModelWall.cs
Game library
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// A wall made from a kit wall model (Blender-made, built along its X axis), stretched to a run and
/// sheared up a slope: a parallelogram with vertical ends and its top and bottom parallel to the
/// ground line, like the Kit Shape wall's Rise. The mesh is a copy of the model's vertices with
/// the model's own materials, so a sloped wall looks like the level ones. Built on load and never
/// saved (like Kit Shape), so the scene file only stores the four numbers.
///
/// Local frame: the run starts at the object's origin and goes along +X for Length (horizontal),
/// rising by Rise; Line Y is the model's local Y that sits on that line (its bounds centre for a
/// wall centred on the line, a side face for a wall standing beside it). The Kit Wall Builder and
/// the hole's Add edge walls button make these for sloped runs.
/// </summary>
[Title( "Kit Model Wall" ), Icon( "fence" )]
public sealed class KitModelWall : Component, Component.ExecuteInEditor
{
	/// <summary>The kit wall model to copy (models/kit/wall_100.vmdl).</summary>
	[Property] public Model Source { get; set; }

	/// <summary>Horizontal length of the run, in units.</summary>
	[Property] public float Length { get; set; } = 100f;

	/// <summary>Height difference over the run: the +x end sits this much higher.</summary>
	[Property] public float Rise { get; set; }

	/// <summary>The model's local Y that lies on the run line.</summary>
	[Property, Title( "Line Y" )] public float LineY { get; set; }

	[Property] public bool Collision { get; set; } = true;

	/// <summary>Material drawn instead of the model's own (the kit wall material); empty keeps the model's.</summary>
	[Property] public Material Material { get; set; }

	/// <summary>World units per texture repeat. The mesh gets box-projected texture coordinates at its stretched size, so the texture repeats along the run instead of stretching with it.</summary>
	[Property, Title( "Texture Repeat" )] public float TextureRepeat { get; set; } = 100f;

	Model _model;
	Model _builtSource;
	Material _builtMaterial;
	float _builtLength, _builtRise, _builtLineY, _builtRepeat;
	bool _builtCollision;

	protected override void OnEnabled() => Rebuild();

	protected override void OnUpdate()
	{
		if ( _model is null || _builtSource != Source || _builtLength != Length || _builtRise != Rise || _builtLineY != LineY || _builtCollision != Collision || _builtMaterial != Material || _builtRepeat != TextureRepeat )
			Rebuild();
	}

	/// <summary>How many kit model walls have been rebuilt since load (see golf_perf).</summary>
	public static int RebuildCount;

	/// <summary>Builds the sheared copy and puts it on this object's renderer and collider.</summary>
	public void Rebuild()
	{
		RebuildCount++;
		_builtSource = Source;
		_builtLength = Length;
		_builtRise = Rise;
		_builtLineY = LineY;
		_builtCollision = Collision;
		_builtMaterial = Material;
		_builtRepeat = TextureRepeat;

		if ( Source is null || Source.IsError ) return;
		var sourceVertices = Source.GetVertices();
		var sourceIndices = Source.GetIndices();
		if ( sourceVertices is null || sourceIndices is null || sourceVertices.Length == 0 || sourceIndices.Length < 3 ) return;

		var bounds = Source.Bounds;
		var length = MathF.Max( 1f, Length );
		var stretch = length / MathF.Max( 1f, bounds.Size.x );
		var slope = Rise / length;

		// Shear: x' = stretch * (x - minX), z' = (z - minZ) + slope * x'. Normals take the inverse
		// transpose, tangents the matrix itself, both renormalised.
		var vertices = new List<SimpleVertex>( sourceVertices.Length );
		var positions = new List<Vector3>( sourceVertices.Length );
		var repeat = MathF.Max( 1f, TextureRepeat );
		foreach ( var v in sourceVertices )
		{
			var x = (v.Position.x - bounds.Mins.x) * stretch;
			var p = new Vector3( x, v.Position.y - LineY, v.Position.z - bounds.Mins.z + slope * x );
			var n = new Vector3( v.Normal.x / stretch - slope * v.Normal.z, v.Normal.y, v.Normal.z ).Normal;
			var t = new Vector3( stretch * v.Tangent.x, v.Tangent.y, slope * stretch * v.Tangent.x + v.Tangent.z ).Normal;
			// Box projection on the stretched mesh: the texture repeats every TextureRepeat units
			// along the run (and up the wall) however long the wall is.
			var ax = MathF.Abs( n.x ); var ay = MathF.Abs( n.y ); var az = MathF.Abs( n.z );
			var uv = az >= ax && az >= ay ? new Vector2( p.x, p.y ) : ax >= ay ? new Vector2( p.y, p.z ) : new Vector2( p.x, p.z );
			vertices.Add( new SimpleVertex( p, n, t, uv / repeat ) );
			positions.Add( p );
		}
		var indices = sourceIndices.Select( i => (int)i ).ToList();

		var material = Source.Materials.FirstOrDefault() ?? Material.Load( "prefabs/holekit/kit.vmat" );
		var mesh = new Mesh( material );
		mesh.CreateVertexBuffer( vertices.Count, vertices );
		mesh.CreateIndexBuffer( indices.Count, indices );
		mesh.Bounds = BBox.FromPoints( positions );

		var builder = Model.Builder.AddMesh( mesh );
		if ( Collision )
		{
			// Physics gets a welded copy so the ball never meets an edge between a face's triangles.
			var welded = new List<Vector3>();
			var weldedIndices = new List<int>();
			var lookup = new Dictionary<(int, int, int), int>();
			foreach ( var i in indices )
			{
				var p = positions[i];
				var key = ((int)MathF.Round( p.x * 16f ), (int)MathF.Round( p.y * 16f ), (int)MathF.Round( p.z * 16f ));
				if ( !lookup.TryGetValue( key, out var w ) )
				{
					w = welded.Count;
					welded.Add( p );
					lookup[key] = w;
				}
				weldedIndices.Add( w );
			}
			builder.AddCollisionMesh( welded, weldedIndices );
		}
		_model = builder.Create();

		// One renderer and one collider, driven from here and never saved (rebuilt on load).
		var renderers = GameObject.GetComponents<ModelRenderer>( true ).ToList();
		var renderer = renderers.FirstOrDefault() ?? GameObject.AddComponent<ModelRenderer>();
		foreach ( var spare in renderers.Skip( 1 ) ) spare.Destroy();
		renderer.Flags |= ComponentFlags.NotSaved;
		renderer.Model = _model;
		renderer.MaterialOverride = Material;

		var colliders = GameObject.GetComponents<ModelCollider>( true ).ToList();
		var collider = colliders.FirstOrDefault();
		foreach ( var spare in colliders.Skip( 1 ) ) spare.Destroy();
		if ( Collision )
		{
			collider ??= GameObject.AddComponent<ModelCollider>();
			collider.Flags |= ComponentFlags.NotSaved;
			collider.Model = _model;
			collider.Static = true;
			collider.Surface = Surface.FindByName( GolfHoleCollision.SurfaceName ) ?? collider.Surface;
			collider.Enabled = true;
		}
		else if ( collider.IsValid() )
		{
			collider.Flags |= ComponentFlags.NotSaved;
			collider.Enabled = false;
		}

		// Under a hole the hole's welded collider takes this piece in (and switches the collider off).
		GameObject.GetComponentInParent<GolfHoleCollision>( true )?.MarkDirty();
	}
}
mrtn.golfkit / MudWaterRipple.cs
Game library
using Sandbox;
using System;
using System.Linq;

/// <summary>
/// Ripples on a mud water plane. Every Interval (a random time between the min and max) a
/// shockwave starts at a random point on the plane: a flat overlay quad carrying a ring texture
/// (three concentric rings in its alpha and normal map, from Tools/make_mud_water.py) is placed on
/// the surface, scaled up at Ring Speed until it reaches Max Radius, and fades to nothing on the
/// way, about two seconds at the defaults. The overlay's fade is the renderer's Tint alpha; the
/// complex shader has no attribute to drive a ripple inside the water material itself, so the
/// overlay is the cheap route in this build. Nothing is networked, every machine ripples on its
/// own; a small pool of overlays, no allocation per ripple. Put it on the object that renders the
/// water plane: the plane's extent is that renderer's model bounds.
///
/// Between ripples the water also sways in place: the plane's material is copied at start and the
/// copy's texture offset is moved round a tiny circle every frame (the complex shader has no
/// wobble or animated normal option, only a straight scroll, so this is done from code). Nothing
/// flows, the texture only drifts back and forth by a fraction of a texel.
/// </summary>
[Title( "Mud Water Ripple" ), Category( "Golf" ), Icon( "water" )]
public sealed class MudWaterRipple : Component
{
	/// <summary>Shortest wait between ripples, seconds.</summary>
	[Property, Range( 0.5f, 60f ), Title( "Interval Min" )]
	public float IntervalMin { get; set; } = 5f;

	/// <summary>Longest wait between ripples, seconds.</summary>
	[Property, Range( 0.5f, 60f ), Title( "Interval Max" )]
	public float IntervalMax { get; set; } = 15f;

	/// <summary>How fast the rings spread, units per second.</summary>
	[Property, Range( 10f, 1000f ), Title( "Ring Speed" )]
	public float RingSpeed { get; set; } = 100f;

	/// <summary>How visible the rings are when they start, 0 to 1.</summary>
	[Property, Range( 0f, 1f ), Title( "Ring Strength" )]
	public float RingStrength { get; set; } = 0.8f;

	/// <summary>Radius the rings reach before they are gone, units.</summary>
	[Property, Range( 20f, 2000f ), Title( "Max Radius" )]
	public float MaxRadius { get; set; } = 200f;

	/// <summary>Ripples in flight at once; the pool size.</summary>
	[Property, Range( 1, 8 )]
	public int MaxRipples { get; set; } = 3;

	/// <summary>Sway the water texture in place between ripples.</summary>
	[Property, Group( "Wobble" )]
	public bool Wobble { get; set; } = true;

	/// <summary>How far the texture sways, in UV units; 0.002 is a fraction of a texel.</summary>
	[Property, Group( "Wobble" ), Range( 0f, 0.02f ), Title( "Wobble Radius" )]
	public float WobbleRadius { get; set; } = 0.002f;

	/// <summary>Seconds for one full sway round the circle.</summary>
	[Property, Group( "Wobble" ), Range( 1f, 30f ), Title( "Wobble Period" )]
	public float WobblePeriod { get; set; } = 6f;

	const string QuadModel = "models/mud_water/ripple_quad.vmdl";
	const string RingMaterial = "textures/mud_water/ripple_ring.vmat";
	const float Lift = 0.75f;   // the overlay floats this far above the water so it never z-fights

	ModelRenderer[] _overlays = Array.Empty<ModelRenderer>();
	float[] _born = Array.Empty<float>();
	int _next;
	float _nextAt;
	ModelRenderer _plane;
	Material _planeMaterial;   // what the plane had before the wobble copy, put back on disable
	Material _wobble;

	protected override void OnStart()
	{
		var model = Model.Load( QuadModel );
		var material = Material.Load( RingMaterial );
		if ( model is null || material is null )
		{
			Log.Warning( $"MudWaterRipple: missing {QuadModel} or {RingMaterial}" );
			return;
		}

		var count = Math.Clamp( MaxRipples, 1, 8 );
		_overlays = new ModelRenderer[count];
		_born = new float[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = new GameObject( GameObject, false, $"ripple {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var renderer = go.AddComponent<ModelRenderer>();
			renderer.Model = model;
			renderer.MaterialOverride = material;
			_overlays[i] = renderer;
		}

		_nextAt = Time.Now + Wait();

		// The wobble works on a private copy of the plane's material, so the asset itself is untouched.
		_plane = GetComponent<ModelRenderer>();
		var source = _plane?.MaterialOverride ?? _plane?.Model?.Materials.FirstOrDefault();
		if ( Wobble && source is not null )
		{
			_planeMaterial = _plane.MaterialOverride;
			_wobble = source.CreateCopy( "mud water wobble" );
			_plane.MaterialOverride = _wobble;
		}
	}

	protected override void OnDisabled()
	{
		foreach ( var overlay in _overlays )
			if ( overlay.IsValid() ) overlay.GameObject.Enabled = false;

		if ( _wobble is not null && _plane.IsValid() && _plane.MaterialOverride == _wobble )
			_plane.MaterialOverride = _planeMaterial;
		_wobble = null;
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor || _overlays.Length == 0 ) return;

		var now = Time.Now;

		if ( _wobble is not null )
		{
			var angle = now / Math.Max( 0.1f, WobblePeriod ) * MathF.PI * 2f;
			var radius = Wobble ? WobbleRadius : 0f;
			_wobble.Set( "g_vTexCoordOffset", new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * radius );
		}
		var life = MaxRadius / Math.Max( 1f, RingSpeed );

		for ( var i = 0; i < _overlays.Length; i++ )
		{
			var overlay = _overlays[i];
			if ( !overlay.IsValid() || !overlay.GameObject.Enabled ) continue;

			var age = now - _born[i];
			if ( age >= life )
			{
				overlay.GameObject.Enabled = false;
				continue;
			}

			var t = age / life;
			var radius = Math.Max( 1f, RingSpeed * age );
			overlay.GameObject.WorldScale = new Vector3( radius, radius, 1f );
			overlay.Tint = Color.White.WithAlpha( RingStrength * (1f - t) * (1f - t) );
		}

		if ( now < _nextAt ) return;
		_nextAt = now + Wait();
		Spawn();
	}

	float Wait()
	{
		var min = Math.Min( IntervalMin, IntervalMax );
		var max = Math.Max( IntervalMin, IntervalMax );
		return Game.Random.Float( min, max );
	}

	/// <summary>A ripple at a random point on the plane's top surface.</summary>
	void Spawn()
	{
		var i = _next;
		_next = (_next + 1) % _overlays.Length;
		var overlay = _overlays[i];
		if ( !overlay.IsValid() ) return;

		var plane = GetComponent<ModelRenderer>();
		var bounds = plane?.Model?.Bounds ?? new BBox( new Vector3( -100f, -100f, 0f ), new Vector3( 100f, 100f, 0f ) );
		var local = new Vector3(
			Game.Random.Float( bounds.Mins.x, bounds.Maxs.x ),
			Game.Random.Float( bounds.Mins.y, bounds.Maxs.y ),
			bounds.Maxs.z );
		var position = WorldTransform.PointToWorld( local ) + Vector3.Up * Lift;

		overlay.GameObject.WorldPosition = position;
		overlay.GameObject.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );
		overlay.GameObject.WorldScale = new Vector3( 1f, 1f, 1f );
		overlay.Tint = Color.White.WithAlpha( RingStrength );
		_born[i] = Time.Now;
		overlay.GameObject.Enabled = true;
	}
}
mrtn.golfkit / Editor/GolfHoleEdgeWalls.cs
Editor library
using Sandbox;
using Editor;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// Inspector for Golf Hole Collision: the component's own rows plus two buttons. "Add edge walls"
/// walls in the hole's outer boundary (every floor tile, ramp and cup tile edge with no neighbouring
/// piece) with the kit post and wall models, placed exactly like the Kit Wall Builder would place
/// them. "Remove edge walls" deletes only the pieces that button made. Editor only; both undo.
/// </summary>
[CustomEditor( typeof( GolfHoleCollision ) )]
public sealed class GolfHoleCollisionEditor : ComponentEditorWidget
{
	public GolfHoleCollisionEditor( SerializedObject obj ) : base( obj )
	{
		Layout = Layout.Column();

		var sheet = new Editor.ControlSheet();
		sheet.IncludePropertyNames = true;
		sheet.AddObject( obj, p => p.HasAttribute<PropertyAttribute>() );
		Layout.Add( sheet );

		var buttons = Layout.AddRow();
		buttons.Margin = 4f;
		buttons.Spacing = 4f;

		var add = new Editor.Button( "Add edge walls", "fence", this );
		add.ToolTip = "Posts and walls along every outer tile edge of this hole (kit post_10 and wall_100 models).";
		add.Clicked = () => GolfHoleEdgeWalls.Add( Hole );
		buttons.Add( add );

		var remove = new Editor.Button( "Remove edge walls", "delete", this );
		remove.ToolTip = "Deletes the posts and walls that Add edge walls made under this hole.";
		remove.Clicked = () => GolfHoleEdgeWalls.Remove( Hole );
		buttons.Add( remove );
	}

	GolfHoleCollision Hole => SerializedObject.Targets?.OfType<GolfHoleCollision>().FirstOrDefault();
}

/// <summary>
/// Finds the outer boundary of a hole's tile pieces and builds posts and walls along it. An edge is
/// on the boundary when no other piece has the same edge (same two end points in 3D, so two ramps
/// side by side join and a tile at another height does not). Where two boundary edges share the
/// same footprint at different heights (a ramp's sloped side beside a flat tile, or a height step)
/// only the higher one is walled: the lower one lies against the higher piece's side. The boundary
/// edges are joined into loops, straight runs are merged, a post stands at every corner and one
/// wall runs between posts; a sloped wall is sheared (Kit Model Wall) so it follows the surface.
/// Walls stand on the tile with their outer face on the edge line, foot (if the model has one)
/// towards the play side.
/// </summary>
public static class GolfHoleEdgeWalls
{
	/// <summary>Tag on every object these buttons create, so Remove touches nothing else.</summary>
	public const string Tag = "golf_edgewall";

	const string WallModelPath = "models/kit/wall_100.vmdl";
	const string PostModelPath = "models/kit/post_10.vmdl";

	/// <summary>Points closer than 1/4 unit are the same point.</summary>
	const float Grid = 4f;

	public static void Add( GolfHoleCollision hole )
	{
		if ( !hole.IsValid() ) return;
		var session = SceneEditorSession.Resolve( hole.GameObject ) ?? SceneEditorSession.Active;
		if ( session is null ) return;

		var wallModel = KitWallBuilderTool.LoadKitModel( WallModelPath );
		var postModel = KitWallBuilderTool.LoadKitModel( PostModelPath );
		if ( wallModel is null || postModel is null )
		{
			Log.Warning( $"Add edge walls: {WallModelPath} or {PostModelPath} is missing" );
			return;
		}

		var loops = BoundaryLoops( hole.GameObject );
		if ( loops.Count == 0 )
		{
			Log.Info( $"Add edge walls: no tile edges found under {hole.GameObject.Name}" );
			return;
		}

		var footSide = FootSide( wallModel );
		var bounds = wallModel.Bounds;
		Log.Info( $"Add edge walls: wall model bounds x {bounds.Mins.x}..{bounds.Maxs.x}, y {bounds.Mins.y}..{bounds.Maxs.y}, z {bounds.Mins.z}..{bounds.Maxs.z}; foot side {(footSide > 0 ? "+y" : footSide < 0 ? "-y" : "none")}" );

		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Add edge walls" ).WithGameObjectCreations().Push();

		var parent = hole.GameObject;
		var posts = 0;
		var walls = 0;
		foreach ( var loop in loops )
		{
			foreach ( var point in loop.Points )
			{
				var post = KitWallBuilderTool.CreateModelObject( parent, "Post", postModel, point - KitWallBuilderTool.BottomCentre( postModel.Bounds ), Rotation.Identity, Vector3.One );
				post.Tags.Add( Tag );
				posts++;
			}

			var segments = loop.Closed ? loop.Points.Count : loop.Points.Count - 1;
			for ( var i = 0; i < segments; i++ )
			{
				var from = loop.Points[i];
				var to = loop.Points[(i + 1) % loop.Points.Count];
				var inward = loop.Inward[i];
				if ( (to - from).WithZ( 0f ).Length < 1f ) continue;

				// The wall stands on the tile: its outer face on the edge line, the play side (and the
				// foot, when the model has one) towards the tiles. The model's +Y is the run's left; the
				// run is reversed when the foot would end up outside.
				var inwardOnLeft = Vector3.Dot( Rotation.LookAt( (to - from).WithZ( 0f ).Normal, Vector3.Up ).Left, inward ) > 0f;
				var wantLeft = footSide >= 0;
				if ( inwardOnLeft != wantLeft ) (from, to) = (to, from);
				var lineY = wantLeft ? bounds.Mins.y : bounds.Maxs.y;

				var wall = KitWallBuilderTool.PlaceWallPiece( parent, wallModel, from, to, lineY );
				if ( wall is null ) continue;
				wall.Tags.Add( Tag );
				walls++;
			}
		}

		hole.MarkDirty();
		Log.Info( $"Add edge walls: {hole.GameObject.Name}: {posts} posts and {walls} walls along {loops.Count} loop(s)" );
	}

	public static void Remove( GolfHoleCollision hole )
	{
		if ( !hole.IsValid() ) return;
		var session = SceneEditorSession.Resolve( hole.GameObject ) ?? SceneEditorSession.Active;
		if ( session is null ) return;

		var made = hole.GameObject.Children.Where( go => go.IsValid() && go.Tags.Has( Tag ) ).ToList();
		if ( made.Count == 0 )
		{
			Log.Info( $"Remove edge walls: nothing to remove under {hole.GameObject.Name}" );
			return;
		}

		using var sceneScope = session.Scene.Push();
		using var undo = session.UndoScope( "Remove edge walls" ).WithGameObjectDestructions( made ).Push();
		foreach ( var go in made ) go.Destroy();

		hole.MarkDirty();
		Log.Info( $"Remove edge walls: {hole.GameObject.Name}: removed {made.Count} pieces" );
	}

	/// <summary>
	/// Which side of the wall model is wider at the base than higher up: +1 for +Y, -1 for -Y,
	/// 0 for a wall with the same width all the way up.
	/// </summary>
	static int FootSide( Model model )
	{
		var vertices = model.GetVertices();
		if ( vertices is null || vertices.Length == 0 ) return 0;
		var bounds = model.Bounds;
		var baseTop = bounds.Mins.z + 1f;
		var upperFrom = bounds.Mins.z + bounds.Size.z * 0.5f;
		var baseMin = float.MaxValue; var baseMax = float.MinValue;
		var upperMin = float.MaxValue; var upperMax = float.MinValue;
		foreach ( var v in vertices )
		{
			if ( v.Position.z <= baseTop ) { baseMin = MathF.Min( baseMin, v.Position.y ); baseMax = MathF.Max( baseMax, v.Position.y ); }
			if ( v.Position.z >= upperFrom ) { upperMin = MathF.Min( upperMin, v.Position.y ); upperMax = MathF.Max( upperMax, v.Position.y ); }
		}
		if ( baseMin == float.MaxValue || upperMin == float.MaxValue ) return 0;
		var plus = baseMax - upperMax;
		var minus = upperMin - baseMin;
		if ( plus > 0.5f && plus >= minus ) return 1;
		if ( minus > 0.5f ) return -1;
		return 0;
	}

	// ---------------------------------------------------------------- boundary

	sealed class Loop
	{
		public List<Vector3> Points = new();
		/// <summary>Horizontal direction into the tiles for the segment starting at each point.</summary>
		public List<Vector3> Inward = new();
		public bool Closed;
	}

	record struct Edge( Key A, Key B, Vector3 Inward, float Height );

	/// <summary>
	/// The boundary of the tile pieces as point loops in world space, corners only (straight runs
	/// merged), each with the inward direction of every segment.
	/// </summary>
	static List<Loop> BoundaryLoops( GameObject holeObject )
	{
		// Every top-surface edge of every piece, counted by its two end points in 3D.
		var edges = new Dictionary<(Key, Key), (Edge edge, int count)>();
		foreach ( var renderer in holeObject.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
		{
			var go = renderer.GameObject;
			if ( !go.IsValid() || !go.Enabled || go == holeObject || go.Tags.Has( Tag ) ) continue;
			var corners = TopCorners( renderer.Model );
			if ( corners is null ) continue;

			var world = corners.Select( c => go.WorldTransform.PointToWorld( c ) ).ToArray();
			var centre = go.WorldTransform.PointToWorld( Vector3.Zero );
			for ( var i = 0; i < world.Length; i++ )
			{
				var a = world[i];
				var b = world[(i + 1) % world.Length];
				var ka = Key.Of( a );
				var kb = Key.Of( b );
				var key = ka.CompareTo( kb ) <= 0 ? (ka, kb) : (kb, ka);
				var inward = (centre - (a + b) * 0.5f).WithZ( 0f ).Normal;
				edges[key] = edges.TryGetValue( key, out var e )
					? (e.edge, e.count + 1)
					: (new Edge( key.Item1, key.Item2, inward, key.Item1.Point.z + key.Item2.Point.z ), 1);
			}
		}

		// Boundary edges: used by one piece only. Of the edges sharing one footprint at different
		// heights only the highest is kept: the lower lies against the higher piece's side.
		var open = edges.Values.Where( e => e.count == 1 ).Select( e => e.edge )
			.GroupBy( e => (e.A.Flat, e.B.Flat) )
			.Select( g => g.OrderByDescending( e => e.Height ).First() )
			.ToList();

		var byPoint = new Dictionary<Key, List<int>>();
		for ( var i = 0; i < open.Count; i++ )
		{
			foreach ( var end in new[] { open[i].A, open[i].B } )
			{
				if ( !byPoint.TryGetValue( end, out var list ) ) byPoint[end] = list = new List<int>();
				list.Add( i );
			}
		}

		// Walk the edges into chains; a chain that comes back to its first point is a closed loop.
		var used = new bool[open.Count];
		var loops = new List<Loop>();
		for ( var start = 0; start < open.Count; start++ )
		{
			if ( used[start] ) continue;
			used[start] = true;
			var chain = new List<Key> { open[start].A, open[start].B };
			var inward = new List<Vector3> { open[start].Inward };
			var closed = false;
			while ( true )
			{
				var tail = chain[^1];
				var next = byPoint[tail].FirstOrDefault( i => !used[i], -1 );
				if ( next < 0 ) break;
				used[next] = true;
				var other = open[next].A.Equals( tail ) ? open[next].B : open[next].A;
				inward.Add( open[next].Inward );
				if ( other.Equals( chain[0] ) ) { closed = true; break; }
				chain.Add( other );
			}
			loops.Add( MergeStraight( chain.Select( k => k.Point ).ToList(), inward, closed ) );
		}
		return loops;
	}

	/// <summary>Corners of a piece's top surface in piece space, in order around it; null for pieces that are not tiles.</summary>
	static Vector3[] TopCorners( Model model )
	{
		var name = model?.Name ?? "";
		if ( name.Contains( "ramp", StringComparison.OrdinalIgnoreCase ) )
			return new[] { new Vector3( -100, -100, 0 ), new Vector3( 100, -100, KitShape.RampRise ), new Vector3( 100, 100, KitShape.RampRise ), new Vector3( -100, 100, 0 ) };
		// The kit floor tile, the cup tiles, the trench cup tile and every trench floor piece (floor,
		// floor_corner, floor_end, floor_mud_pit, floor_t_tile, floor_x_tile): 200 x 200 tiles, top at z 0.
		if ( name.Contains( "floor", StringComparison.OrdinalIgnoreCase ) || name.Contains( "cup_", StringComparison.OrdinalIgnoreCase ) || name.Contains( "trench_hole", StringComparison.OrdinalIgnoreCase ) )
			return new[] { new Vector3( -100, -100, 0 ), new Vector3( 100, -100, 0 ), new Vector3( 100, 100, 0 ), new Vector3( -100, 100, 0 ) };
		return null;
	}

	/// <summary>
	/// Drops every point where the direction does not change, so one wall covers a straight run.
	/// Segment i runs from point i to point i + 1 (back to 0 on a closed loop) with inward[i].
	/// </summary>
	static Loop MergeStraight( List<Vector3> points, List<Vector3> inward, bool closed )
	{
		var loop = new Loop { Closed = closed };
		if ( points.Count < 3 )
		{
			loop.Points = points;
			loop.Inward = inward;
			return loop;
		}
		for ( var i = 0; i < points.Count; i++ )
		{
			var isEnd = !closed && (i == 0 || i == points.Count - 1);
			if ( !isEnd )
			{
				var prev = points[(i - 1 + points.Count) % points.Count];
				var next = points[(i + 1) % points.Count];
				if ( Vector3.Dot( (points[i] - prev).Normal, (next - points[i]).Normal ) >= 0.9999f ) continue;
			}
			loop.Points.Add( points[i] );
			loop.Inward.Add( i < inward.Count ? inward[i] : inward[^1] );
		}
		return loop;
	}

	/// <summary>A world point snapped to the grid, usable as a dictionary key.</summary>
	readonly record struct Key( int X, int Y, int Z ) : IComparable<Key>
	{
		public static Key Of( Vector3 p ) => new( (int)MathF.Round( p.x * Grid ), (int)MathF.Round( p.y * Grid ), (int)MathF.Round( p.z * Grid ) );
		public Vector3 Point => new( X / Grid, Y / Grid, Z / Grid );
		public (int, int) Flat => (X, Y);
		public int CompareTo( Key other ) => X != other.X ? X.CompareTo( other.X ) : Y != other.Y ? Y.CompareTo( other.Y ) : Z.CompareTo( other.Z );
	}
}
mrtn.golfkit / Code/GolfMixers.cs
Game library
using Sandbox;
using Sandbox.Audio;

/// <summary>
/// The game's own mixers, one per kind of sound, made under the master mixer the first time they
/// are asked for and found again by name after that (so a hotload or a scene change never makes a
/// second set). Each source sets its handles' TargetMixer to the right one; the guns stay on the
/// default mixer. <see cref="GolfAudioZone"/> filters and ducks these mixers.
/// </summary>
public static class GolfMixers
{
	public const string MusicName = "Golf Music";
	public const string AmbienceName = "Golf Ambience";
	public const string SoundsName = "Golf Sounds";
	public const string ShellingName = "Trench Shelling";
	/// <summary>The distraction hit's ping and ear ring: never muffled, so a hit is always heard.</summary>
	public const string HitName = "Golf Hit";

	public static Mixer Music => Get( MusicName );
	public static Mixer Ambience => Get( AmbienceName );
	public static Mixer Sounds => Get( SoundsName );
	public static Mixer Shelling => Get( ShellingName );
	public static Mixer Hit => Get( HitName );

	/// <summary>The mixer with this name under the master, made if it is not there yet. Null before the audio engine is up.</summary>
	public static Mixer Get( string name )
	{
		var master = Mixer.Master;
		if ( master is null ) return null;

		var mixer = Mixer.FindMixerByName( name );
		if ( mixer is not null ) return mixer;

		mixer = master.AddChild();
		mixer.Name = name;
		return mixer;
	}
}
mrtn.golfkit / Code/TrenchFire.cs
Game library
using Sandbox;
using System;

/// <summary>
/// Decorative machine-gun fire for the trench map: bursts of glowing tracers fly from a random
/// point on the Start line to a random point on the End line, with a muzzle flash where they leave
/// and a dust puff where they land, and a distant, softened shot sound for every round. Purely a
/// show: nothing is traced against players or balls, nothing takes damage, nothing is networked.
/// Every machine runs its own with its own random timing.
///
/// The two lines are each two child objects (Start A/B, End A/B) that you move with the normal
/// move gizmo; they are created the first time the component is seen in the editor if missing.
/// The lines and the handles are drawn as gizmos. The component's own Enabled checkbox turns the
/// gun off. Small pool of tracers and flash lights, nothing allocated per shot but the engine's
/// own sound handle and the surface's impact particle.
/// </summary>
[Title( "Trench Fire" ), Category( "Golf" ), Icon( "whatshot" )]
public sealed class TrenchFire : Component
{
	/// <summary>One end of the line the tracers leave from.</summary>
	[Property, Group( "Lines" ), Title( "Start A" )]
	public GameObject StartA { get; set; }

	/// <summary>The other end of the line the tracers leave from.</summary>
	[Property, Group( "Lines" ), Title( "Start B" )]
	public GameObject StartB { get; set; }

	/// <summary>One end of the line the tracers land on.</summary>
	[Property, Group( "Lines" ), Title( "End A" )]
	public GameObject EndA { get; set; }

	/// <summary>The other end of the line the tracers land on.</summary>
	[Property, Group( "Lines" ), Title( "End B" )]
	public GameObject EndB { get; set; }

	/// <summary>Random scatter around the landing point, in units.</summary>
	[Property, Group( "Lines" ), Range( 0f, 300f )]
	public float Spread { get; set; } = 40f;

	/// <summary>Bursts per minute.</summary>
	[Property, Group( "Timing" ), Range( 0.1f, 60f ), Title( "Rate (bursts per minute)" )]
	public float Rate { get; set; } = 6f;

	/// <summary>Fewest shots in a burst.</summary>
	[Property, Group( "Timing" ), Range( 1, 60 ), Title( "Burst Length Min" )]
	public int BurstMin { get; set; } = 8;

	/// <summary>Most shots in a burst.</summary>
	[Property, Group( "Timing" ), Range( 1, 60 ), Title( "Burst Length Max" )]
	public int BurstMax { get; set; } = 15;

	/// <summary>Shots per second inside a burst.</summary>
	[Property, Group( "Timing" ), Range( 1f, 30f )]
	public float ShotsPerSecond { get; set; } = 12f;

	/// <summary>How much the pause between bursts varies, 0 for a metronome, 1 for anything from no pause to double.</summary>
	[Property, Group( "Timing" ), Range( 0f, 1f )]
	public float PauseVariance { get; set; } = 0.5f;

	/// <summary>How fast a tracer flies, units per second.</summary>
	[Property, Group( "Tracer" ), Range( 500f, 20000f )]
	public float TracerSpeed { get; set; } = 6000f;

	/// <summary>Colour of the tracer streak, the flash and its light.</summary>
	[Property, Group( "Tracer" )]
	public Color TracerColour { get; set; } = new Color( 1f, 0.55f, 0.15f );

	/// <summary>Length of the streak, in units.</summary>
	[Property, Group( "Tracer" ), Range( 10f, 1000f )]
	public float TracerLength { get; set; } = 160f;

	/// <summary>Width of the streak at its bright end, in units.</summary>
	[Property, Group( "Tracer" ), Range( 0.5f, 10f )]
	public float TracerWidth { get; set; } = 2.5f;

	/// <summary>How much brighter than the colour the streak glows.</summary>
	[Property, Group( "Tracer" ), Range( 1f, 8f )]
	public float Glow { get; set; } = 3f;

	/// <summary>A short flash of light at the start point for every shot.</summary>
	[Property, Group( "Effects" )]
	public bool MuzzleFlash { get; set; } = true;

	/// <summary>The surface's bullet impact particle where a tracer lands.</summary>
	[Property, Group( "Effects" )]
	public bool DustPuff { get; set; } = true;

	/// <summary>The distant shot, played at the start point for every round. Empty uses sounds/trench_fire.</summary>
	[Property, Group( "Sound" )]
	public SoundEvent ShotSound { get; set; }

	/// <summary>Volume of the shots, 0 to 1.</summary>
	[Property, Group( "Sound" ), Range( 0f, 1f )]
	public float Volume { get; set; } = 0.35f;

	const string DefaultSound = "sounds/trench_fire.sound";
	const float FlashSeconds = 0.05f;
	const float FlashRadius = 160f;

	struct Landing
	{
		public float At;        // Time.Now the tracer arrives; 0 for none pending
		public Vector3 Position;
		public Vector3 Normal;
		public Surface Surface;
	}

	TracerEffect[] _tracers = Array.Empty<TracerEffect>();
	Landing[] _landings = Array.Empty<Landing>();
	PointLight[] _flashes = Array.Empty<PointLight>();
	float[] _flashOff = Array.Empty<float>();
	int _nextTracer;
	int _nextFlash;
	Surface _defaultSurface;
	Color _builtColour;
	float _builtWidth;

	float _nextBurstAt;
	float _nextShotAt;
	int _shotsLeft;

	protected override void OnStart()
	{
		EnsureHandles();
		ShotSound ??= ResourceLibrary.Get<SoundEvent>( DefaultSound );
		_defaultSurface = Surface.FindByName( "default" );
		BuildPool();
		_nextBurstAt = Time.Now + NextPause() * Game.Random.Float( 0f, 1f );
	}

	protected override void OnDisabled()
	{
		_shotsLeft = 0;
		for ( var i = 0; i < _landings.Length; i++ ) _landings[i].At = 0f;
		foreach ( var flash in _flashes ) if ( flash.IsValid() ) flash.Enabled = false;
	}

	protected override void OnUpdate()
	{
		if ( Scene.IsEditor ) return;
		if ( _tracers.Length == 0 ) return;

		var now = Time.Now;

		// Flashes go out, landings puff.
		for ( var i = 0; i < _flashes.Length; i++ )
		{
			if ( _flashOff[i] > 0f && now >= _flashOff[i] )
			{
				_flashOff[i] = 0f;
				if ( _flashes[i].IsValid() ) _flashes[i].Enabled = false;
			}
		}

		for ( var i = 0; i < _landings.Length; i++ )
		{
			if ( _landings[i].At <= 0f || now < _landings[i].At ) continue;
			_landings[i].At = 0f;
			if ( DustPuff )
				BaseCombatWeapon.ImpactPrefab( null, _landings[i].Surface, _landings[i].Position, _landings[i].Normal );
		}

		// Bursts.
		if ( _shotsLeft <= 0 )
		{
			if ( now < _nextBurstAt ) return;
			_shotsLeft = Game.Random.Int( Math.Min( BurstMin, BurstMax ), Math.Max( BurstMin, BurstMax ) );
			_nextShotAt = now;
			_nextBurstAt = now + NextPause();
		}

		if ( now < _nextShotAt ) return;
		_nextShotAt = now + 1f / Math.Max( 1f, ShotsPerSecond );
		_shotsLeft--;
		Fire();
	}

	float NextPause()
	{
		var mean = 60f / Math.Max( 0.1f, Rate );
		return mean * (1f + Game.Random.Float( -PauseVariance, PauseVariance ));
	}

	void Fire()
	{
		if ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;

		var start = Vector3.Lerp( StartA.WorldPosition, StartB.WorldPosition, Game.Random.Float( 0f, 1f ) );
		var end = Vector3.Lerp( EndA.WorldPosition, EndB.WorldPosition, Game.Random.Float( 0f, 1f ) )
			+ Vector3.Random * Spread;

		// Where it lands: the ground near the end point, for the puff's surface and facing.
		var landing = new Landing { Position = end, Normal = Vector3.Up, Surface = _defaultSurface };
		var trace = Scene.Trace.Ray( end + Vector3.Up * 48f, end + Vector3.Down * 96f ).WithoutTags( "player", "golfball" ).Run();
		if ( trace.Hit )
		{
			landing.Position = trace.HitPosition;
			landing.Normal = trace.Normal;
			landing.Surface = trace.Surface ?? _defaultSurface;
		}

		var distance = start.Distance( end );
		landing.At = Time.Now + distance / Math.Max( 1f, TracerSpeed );

		// Tracer: the pool's oldest one restarts from the new start point.
		var i = _nextTracer;
		_nextTracer = (_nextTracer + 1) % _tracers.Length;
		var tracer = _tracers[i];
		if ( tracer.IsValid() )
		{
			if ( _builtColour != TracerColour || _builtWidth != TracerWidth ) BuildLook();
			tracer.Enabled = false;
			tracer.WorldPosition = start;
			tracer.EndPoint = end;
			tracer.DistancePerSecond = TracerSpeed;
			tracer.Length = TracerLength;
			tracer.Enabled = true;
		}
		_landings[i] = landing;

		// Muzzle flash.
		if ( MuzzleFlash && _flashes.Length > 0 )
		{
			var f = _nextFlash;
			_nextFlash = (_nextFlash + 1) % _flashes.Length;
			var flash = _flashes[f];
			if ( flash.IsValid() )
			{
				flash.WorldPosition = start + (end - start).Normal * 12f;
				flash.LightColor = TracerColour * Glow;
				flash.Radius = FlashRadius;
				flash.Enabled = true;
				_flashOff[f] = Time.Now + FlashSeconds;
			}
		}

		// The distant shot.
		if ( ShotSound is not null && Volume > 0f )
		{
			var handle = Sound.Play( ShotSound, start );
			if ( handle.IsValid() ) handle.Volume = Volume;
		}
	}

	/// <summary>
	/// Enough tracers to cover the longest flight at the burst rate, and a couple of flash lights.
	/// Pool objects are hidden children that are never saved or networked.
	/// </summary>
	void BuildPool()
	{
		var longest = 0f;
		if ( StartA.IsValid() && StartB.IsValid() && EndA.IsValid() && EndB.IsValid() )
		{
			longest = Math.Max( Math.Max( StartA.WorldPosition.Distance( EndA.WorldPosition ), StartA.WorldPosition.Distance( EndB.WorldPosition ) ),
				Math.Max( StartB.WorldPosition.Distance( EndA.WorldPosition ), StartB.WorldPosition.Distance( EndB.WorldPosition ) ) );
		}
		var flight = (longest + Spread + TracerLength) / Math.Max( 1f, TracerSpeed );
		var count = Math.Clamp( (int)MathF.Ceiling( flight * ShotsPerSecond ) + 2, 4, 32 );

		_tracers = new TracerEffect[count];
		_landings = new Landing[count];
		for ( var i = 0; i < count; i++ )
		{
			var go = new GameObject( GameObject, false, $"tracer {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var tracer = go.AddComponent<TracerEffect>( false );
			tracer.StartDistance = 0f;
			tracer.Opaque = false;
			tracer.CastShadows = false;
			tracer.EnableLight = false;
			_tracers[i] = tracer;
			go.Enabled = true;
		}
		BuildLook();

		_flashes = new PointLight[2];
		_flashOff = new float[2];
		for ( var i = 0; i < _flashes.Length; i++ )
		{
			var go = new GameObject( GameObject, false, $"flash {i}" );
			go.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;
			var light = go.AddComponent<PointLight>( false );
			light.Shadows = false;
			light.Radius = FlashRadius;
			light.Attenuation = 2f;
			_flashes[i] = light;
			go.Enabled = true;
		}
	}

	void BuildLook()
	{
		_builtColour = TracerColour;
		_builtWidth = TracerWidth;
		var head = TracerColour * Glow;
		head.a = 1f;
		var colour = new Gradient( new Gradient.ColorFrame( 0f, head ), new Gradient.ColorFrame( 0.35f, TracerColour ), new Gradient.ColorFrame( 1f, TracerColour.WithAlpha( 0f ) ) );
		var width = new Curve( new Curve.Frame( 0f, TracerWidth ), new Curve.Frame( 1f, 0f ) );
		foreach ( var tracer in _tracers )
		{
			if ( !tracer.IsValid() ) continue;
			tracer.LineColor = colour;
			tracer.LineWidth = width;
		}
	}

	/// <summary>
	/// The four handle objects: found by name under this object, or made with default spots.
	/// </summary>
	void EnsureHandles()
	{
		StartA = Handle( StartA, "Start A", new Vector3( 0f, -150f, 40f ) );
		StartB = Handle( StartB, "Start B", new Vector3( 0f, 150f, 40f ) );
		EndA = Handle( EndA, "End A", new Vector3( 1200f, -250f, 0f ) );
		EndB = Handle( EndB, "End B", new Vector3( 1200f, 250f, 0f ) );
	}

	GameObject Handle( GameObject current, string name, Vector3 localPosition )
	{
		if ( current.IsValid() ) return current;

		foreach ( var child in GameObject.Children )
			if ( child.IsValid() && child.Name == name ) return child;

		var go = new GameObject( GameObject, true, name );
		go.LocalPosition = localPosition;
		return go;
	}

	protected override void DrawGizmos()
	{
		EnsureHandles();
		if ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;

		var alpha = Gizmo.IsSelected ? 1f : (Gizmo.IsHovered ? 0.6f : 0.3f);
		var local = WorldTransform;
		var sa = local.PointToLocal( StartA.WorldPosition );
		var sb = local.PointToLocal( StartB.WorldPosition );
		var ea = local.PointToLocal( EndA.WorldPosition );
		var eb = local.PointToLocal( EndB.WorldPosition );

		Gizmo.Draw.LineThickness = Gizmo.IsSelected ? 3f : 2f;
		Gizmo.Draw.Color = TracerColour.WithAlpha( alpha );
		Gizmo.Draw.Line( sa, sb );
		Gizmo.Draw.LineSphere( new Sphere( sa, 8f ), 8 );
		Gizmo.Draw.LineSphere( new Sphere( sb, 8f ), 8 );

		Gizmo.Draw.Color = Color.Red.WithAlpha( alpha );
		Gizmo.Draw.Line( ea, eb );
		Gizmo.Draw.LineSphere( new Sphere( ea, 8f ), 8 );
		Gizmo.Draw.LineSphere( new Sphere( eb, 8f ), 8 );

		Gizmo.Draw.LineThickness = 1f;
		Gizmo.Draw.Color = Color.White.WithAlpha( alpha * 0.7f );
		Gizmo.Draw.Arrow( (sa + sb) * 0.5f, (ea + eb) * 0.5f, 24f, 8f );
	}
}
mrtn.golfkit / GolfMenuCamera.cs
Game library
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 );
		}
	}
}
Debug: View Raw JSON Response
{
    "TotalCount": 40,
    "Files": [
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/GolfBarbedWire.cs",
            "FileName": "GolfBarbedWire.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Barbed wire: a trigger volume on the wire model. A player in it walks at a fraction of their\r\n/// speed and is scraped for a share of their health every so often, playing and finished players\r\n/// alike, with a scrape sound; a player who hasn't finished the hole is never killed by it (the\r\n/// scrape stops at 1 hp for them). A ball in it is dragged down towards a fraction of its speed\r\n/// while it stays in, without being blocked. The host decides everything from what the trigger\r\n/// is touching each physics step: who is inside, when a scrape is due and the ball drag. Players\r\n/// are the bodies tagged \"player\"; the damage and the slow are the game's, told through\r\n/// <see cref=\"IGolfCourseEvents\"/> (entered, left, scrape). The scrape sound is played from here\r\n/// when the game asks (<see cref=\"PlayScrape\"/>).\r\n/// </summary>\r\n[Title( \"Golf Barbed Wire\" ), Icon( \"fence\" )]\r\npublic sealed class GolfBarbedWire : Component\r\n{\r\n\t/// <summary>Damage tag of a scrape, so the golf damage rules let it through.</summary>\r\n\tpublic const string DamageTag = \"barbedwire\";\r\n\r\n\t/// <summary>Move speed in the wire as a fraction of normal: 0.5 is half speed.</summary>\r\n\t[Property, Title( \"Slow Factor\" ), Range( 0.1f, 1f )]\r\n\tpublic float SlowFactor { get; set; } = 0.5f;\r\n\r\n\t/// <summary>Health taken per scrape, as a percentage of max health.</summary>\r\n\t[Property, Title( \"Damage Percent\" ), Range( 0f, 100f )]\r\n\tpublic float DamagePercent { get; set; } = 10f;\r\n\r\n\t/// <summary>Seconds between scrapes while a player stays in the wire. The first scrape is on entry.</summary>\r\n\t[Property, Title( \"Damage Interval\" ), Range( 0.25f, 10f )]\r\n\tpublic float DamageInterval { get; set; } = 2f;\r\n\r\n\t/// <summary>\r\n\t/// How fast a ball in the wire loses speed, per second, towards <see cref=\"SlowFactor\"/> of the\r\n\t/// fastest it has gone since it came in. 6 takes it most of the way in a third of a second.\r\n\t/// </summary>\r\n\t[Property, Title( \"Ball Drag\" ), Range( 0.1f, 20f )]\r\n\tpublic float BallDrag { get; set; } = 6f;\r\n\r\n\t/// <summary>The scrape, played at the player for everyone nearby. Empty: a metal scrape from the engine.</summary>\r\n\t[Property, Group( \"Sound\" )]\r\n\tpublic SoundEvent ScrapeSound { get; set; }\r\n\r\n\t[Property, Group( \"Sound\" ), Range( 0f, 3f )]\r\n\tpublic float ScrapeVolume { get; set; } = 0.6f;\r\n\r\n\t[Property, Group( \"Sound\" ), Range( 0.3f, 2f )]\r\n\tpublic float ScrapePitch { get; set; } = 0.8f;\r\n\r\n\tconst string DefaultScrape = \"sounds/impacts/melee/impact-melee-metal.sound\";\r\n\r\n\t/// <summary>The tag on a player's body; the object carrying it (not a child that inherits it) is the walker.</summary>\r\n\tpublic const string WalkerTag = \"player\";\r\n\r\n\t// Host: which bodies are in this wire and how long since their last scrape; which balls are in\r\n\t// it and the fastest each has gone since it came in.\r\n\treadonly Dictionary<GameObject, RealTimeSince> _walkers = new();\r\n\treadonly Dictionary<IGolfCourseBall, float> _balls = new();\r\n\treadonly HashSet<GameObject> _walkersNow = new();\r\n\treadonly HashSet<IGolfCourseBall> _ballsNow = new();\r\n\r\n\tCollider _trigger;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_trigger = GetComponent<Collider>();\r\n\t\tif ( !_trigger.IsValid() || !_trigger.IsTrigger )\r\n\t\t\tLog.Warning( $\"GolfBarbedWire: {GameObject.Name} has no trigger collider; the wire does nothing\" );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tif ( !Networking.IsHost ) return;\r\n\t\tforeach ( var walker in _walkers.Keys.ToList() )\r\n\t\t\tLeave( walker, \"wire disabled\" );\r\n\t\t_walkers.Clear();\r\n\t\t_balls.Clear();\r\n\t}\r\n\r\n\t/// <summary>The walker a touched collider belongs to: the nearest object up the chain that carries the tag itself.</summary>\r\n\tstatic GameObject WalkerOf( GameObject touched )\r\n\t{\r\n\t\tfor ( var go = touched; go.IsValid() && go is not global::Sandbox.Scene; go = go.Parent )\r\n\t\t\tif ( go.Tags.Has( WalkerTag, false ) ) return go;\r\n\t\treturn null;\r\n\t}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tif ( !Networking.IsHost || !_trigger.IsValid() ) return;\r\n\r\n\t\t// What the trigger overlaps right now, from the physics itself: no enter or exit event to miss.\r\n\t\t_walkersNow.Clear();\r\n\t\t_ballsNow.Clear();\r\n\t\tforeach ( var other in _trigger.Touching )\r\n\t\t{\r\n\t\t\tif ( !other.IsValid() || !other.GameObject.IsValid() ) continue;\r\n\t\t\tvar walker = WalkerOf( other.GameObject );\r\n\t\t\tif ( walker.IsValid() ) { _walkersNow.Add( walker ); continue; }\r\n\t\t\tvar ball = other.GameObject.GetComponentInParent<IGolfCourseBall>();\r\n\t\t\tif ( ball is not null && ball.GameObject.IsValid() ) _ballsNow.Add( ball );\r\n\t\t}\r\n\r\n\t\t// Walkers: newcomers are reported and scraped at once, stayers on the interval, leavers released.\r\n\t\tforeach ( var walker in _walkers.Keys.ToList() )\r\n\t\t{\r\n\t\t\tif ( walker.IsValid() && _walkersNow.Contains( walker ) ) continue;\r\n\t\t\tLeave( walker, walker.IsValid() ? \"left\" : \"gone\" );\r\n\t\t\t_walkers.Remove( walker );\r\n\t\t}\r\n\t\tforeach ( var walker in _walkersNow )\r\n\t\t{\r\n\t\t\tif ( !_walkers.ContainsKey( walker ) )\r\n\t\t\t{\r\n\t\t\t\t_walkers[walker] = 0f;\r\n\t\t\t\tLog.Info( $\"GolfBarbedWire: {walker.Name} entered the wire at {GameObject.Name}\" );\r\n\t\t\t\tIGolfCourseEvents.Post( x => x.OnWireEntered( this, walker ) );\r\n\t\t\t\tIGolfCourseEvents.Post( x => x.OnWireScrape( this, walker ) );\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tif ( _walkers[walker] < DamageInterval ) continue;\r\n\t\t\t_walkers[walker] = 0f;\r\n\t\t\tIGolfCourseEvents.Post( x => x.OnWireScrape( this, walker ) );\r\n\t\t}\r\n\r\n\t\t// Balls: dragged towards the slow factor of the fastest they've gone in here.\r\n\t\tforeach ( var ball in _balls.Keys.ToList() )\r\n\t\t{\r\n\t\t\tvar alive = ball.GameObject.IsValid();\r\n\t\t\tif ( alive && _ballsNow.Contains( ball ) ) continue;\r\n\t\t\tLog.Info( $\"GolfBarbedWire: {( alive ? ball.OwnerName : \"?\" )}'s ball left the wire at {GameObject.Name}, speed {( alive && ball.Rigidbody.IsValid() ? ball.Rigidbody.Velocity.Length : 0f ):0}\" );\r\n\t\t\t_balls.Remove( ball );\r\n\t\t}\r\n\t\tforeach ( var ball in _ballsNow )\r\n\t\t{\r\n\t\t\tif ( !ball.Rigidbody.IsValid() ) continue;\r\n\t\t\tvar body = ball.Rigidbody;\r\n\t\t\tvar velocity = body.Velocity;\r\n\t\t\tvar speed = velocity.Length;\r\n\r\n\t\t\tif ( !_balls.ContainsKey( ball ) )\r\n\t\t\t{\r\n\t\t\t\t_balls[ball] = speed;\r\n\t\t\t\tLog.Info( $\"GolfBarbedWire: {ball.OwnerName}'s ball entered the wire at {GameObject.Name}, speed {speed:0}\" );\r\n\t\t\t}\r\n\t\t\tif ( ball.IsHoled ) continue;\r\n\r\n\t\t\tvar top = Math.Max( _balls[ball], speed );\r\n\t\t\t_balls[ball] = top;\r\n\r\n\t\t\tvar target = top * SlowFactor;\r\n\t\t\tif ( speed <= target || speed < 1f ) continue;\r\n\r\n\t\t\tvar slowed = MathX.Lerp( speed, target, Math.Clamp( BallDrag * Time.Delta, 0f, 1f ) );\r\n\t\t\tbody.Velocity = velocity * (slowed / speed);\r\n\t\t}\r\n\t}\r\n\r\n\tvoid Leave( GameObject walker, string why )\r\n\t{\r\n\t\tLog.Info( $\"GolfBarbedWire: {( walker.IsValid() ? walker.Name : \"a body\" )} {why} the wire at {GameObject.Name}\" );\r\n\t\tIGolfCourseEvents.Post( x => x.OnWireLeft( this, walker ) );\r\n\t}\r\n\r\n\t/// <summary>The scrape sound at a position, for everyone nearby. The game calls this when a scrape did damage.</summary>\r\n\tpublic void PlayScrape( Vector3 position )\r\n\t{\r\n\t\tScrapeRpc( position, ScrapeSound?.ResourcePath ?? DefaultScrape, ScrapeVolume, ScrapePitch );\r\n\t}\r\n\r\n\t[Rpc.Broadcast]\r\n\tstatic void ScrapeRpc( Vector3 position, string path, float volume, float pitch )\r\n\t{\r\n\t\tvar sound = ResourceLibrary.Get<SoundEvent>( string.IsNullOrEmpty( path ) ? DefaultScrape : path );\r\n\t\tif ( sound is null || volume <= 0f ) return;\r\n\t\tvar handle = Sound.Play( sound, position );\r\n\t\tif ( !handle.IsValid() ) return;\r\n\t\thandle.Volume = volume;\r\n\t\thandle.Pitch = pitch;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/Hole.cs",
            "FileName": "Hole.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\nusing System;\n\n/// <summary>\n/// One hole of the course: where its balls tee off, where the green ends, and the scoring\n/// trigger at the bottom of its cup. Put this on a GameObject together with a trigger collider\n/// (Is Trigger ticked) placed inside the cup, below the opening in the green. The ball gets\n/// there by physics alone: it has to roll over the opening slowly enough to drop in.\n///\n/// When a ball reaches the trigger this reports it (<see cref=\"IGolfCourseEvents.OnBallInCup\"/>)\n/// and the game finishes its owner; a ball nobody has shot is simply put back at the tee by the game.\n/// </summary>\n[Title( \"Golf Hole\" ), Icon( \"golf_course\" )]\npublic sealed class Hole : Component, Component.ITriggerListener\n{\n\t/// <summary>\n\t/// Name shown on the HUD.\n\t/// </summary>\n\t[Property]\n\tpublic string Title { get; set; } = \"\";\n\n\t/// <summary>\n\t/// Expected number of shots.\n\t/// </summary>\n\t[Property, Range( 1, 10 )]\n\tpublic int Par { get; set; } = 3;\n\n\t/// <summary>\n\t/// Where every ball for this hole spawns. If empty, the hole's own position is used.\n\t/// </summary>\n\t[Property]\n\tpublic GameObject Tee { get; set; }\n\n\t/// <summary>\n\t/// Lowest corner of the playable area, in world units. A ball outside the box is out of bounds.\n\t/// </summary>\n\t[Property, Group( \"Bounds\" )]\n\tpublic Vector3 BoundsMin { get; set; } = new Vector3( -640f, -640f, -120f );\n\n\t/// <summary>\n\t/// Highest corner of the playable area, in world units.\n\t/// </summary>\n\t[Property, Group( \"Bounds\" )]\n\tpublic Vector3 BoundsMax { get; set; } = new Vector3( 640f, 640f, 4000f );\n\n\t/// <summary>\n\t/// When on, Bounds Min and Bounds Max are measured from this object (the cup) instead of\n\t/// being world coordinates. The hole kit's cup pieces use this, so a hole can be moved freely.\n\t/// </summary>\n\t[Property, Group( \"Bounds\" )]\n\tpublic bool RelativeBounds { get; set; } = false;\n\n\t/// <summary>Room left round the hole's pieces by Fit To Pieces, in units. The top gets 800.</summary>\n\t[Property, Group( \"Bounds\" ), Range( 0f, 2000f )]\n\tpublic float Margin { get; set; } = 200f;\n\n\t/// <summary>\n\t/// Where the balls start: the Tee object if set, otherwise a <see cref=\"GolfTee\"/> under the\n\t/// same parent group (the kit's Tee Piece), otherwise this object's own position.\n\t/// </summary>\n\tpublic Vector3 TeePosition\n\t{\n\t\tget\n\t\t{\n\t\t\tif ( Tee.IsValid() ) return Tee.WorldPosition;\n\n\t\t\tvar tee = GameObject.Parent?.GetComponentInChildren<GolfTee>( true );\n\t\t\treturn tee.IsValid() ? tee.WorldPosition : WorldPosition;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// True if a world position is inside the playable area.\n\t/// </summary>\n\tpublic bool Contains( Vector3 position )\n\t{\n\t\tvar min = BoundsMin;\n\t\tvar max = BoundsMax;\n\t\tif ( RelativeBounds )\n\t\t{\n\t\t\tmin += WorldPosition;\n\t\t\tmax += WorldPosition;\n\t\t}\n\n\t\treturn position.x >= min.x && position.x <= max.x\n\t\t\t&& position.y >= min.y && position.y <= max.y\n\t\t\t&& position.z >= min.z && position.z <= max.z;\n\t}\n\n\tvoid Component.ITriggerListener.OnTriggerEnter( Collider other )\n\t{\n\t\t// Physics runs everywhere, but only the host decides what counts.\n\t\tif ( !Networking.IsHost ) return;\n\t\tif ( !other.IsValid() || !other.GameObject.IsValid() ) return;\n\n\t\tvar ball = other.GameObject.GetComponentInParent<IGolfCourseBall>();\n\t\tif ( ball is null || ball.IsHoled ) return;\n\n\t\tIGolfCourseEvents.Post( x => x.OnBallInCup( this, ball ) );\n\t}\n\n\t// ---------------------------------------------------------------- editor: the bounds box\n\n\t/// <summary>The bounds as a world-space box.</summary>\n\tBBox WorldBounds\n\t{\n\t\tget\n\t\t{\n\t\t\tvar min = BoundsMin;\n\t\t\tvar max = BoundsMax;\n\t\t\tif ( RelativeBounds )\n\t\t\t{\n\t\t\t\tmin += WorldPosition;\n\t\t\t\tmax += WorldPosition;\n\t\t\t}\n\t\t\treturn new BBox( min, max );\n\t\t}\n\t}\n\n\t/// <summary>Writes a world-space box back to Bounds Min and Bounds Max.</summary>\n\tvoid SetWorldBounds( BBox box )\n\t{\n\t\tvar min = box.Mins;\n\t\tvar max = box.Maxs;\n\t\tif ( RelativeBounds )\n\t\t{\n\t\t\tmin -= WorldPosition;\n\t\t\tmax -= WorldPosition;\n\t\t}\n\t\tBoundsMin = min;\n\t\tBoundsMax = max;\n\t}\n\n\t/// <summary>Changes the bounds in the editor as one undo step.</summary>\n\tvoid ChangeBounds( string what, Vector3 min, Vector3 max )\n\t{\n\t\tif ( BoundsMin == min && BoundsMax == max ) return;\n\t\tusing var undo = Scene.Editor?.UndoScope( what ).WithComponentChanges( this ).Push();\n\t\tBoundsMin = min;\n\t\tBoundsMax = max;\n\t}\n\n\t/// <summary>The \"Hole N\" group this cup belongs to: the nearest ancestor named like one, else the parent, else the cup itself.</summary>\n\tGameObject HoleGroup\n\t{\n\t\tget\n\t\t{\n\t\t\tGameObject fallback = null;\n\t\t\tfor ( var go = GameObject.Parent; go.IsValid() && go is not Sandbox.Scene; go = go.Parent )\n\t\t\t{\n\t\t\t\tfallback ??= go;\n\t\t\t\tif ( System.Text.RegularExpressions.Regex.IsMatch( go.Name ?? \"\", @\"^Hole \\d+\" ) ) return go;\n\t\t\t}\n\t\t\treturn fallback ?? GameObject;\n\t\t}\n\t}\n\n\t/// <summary>\n\t/// Sets the bounds to every renderer under the hole group (the cup's parent group), in world\n\t/// space, plus <see cref=\"Margin\"/> on each side in X and Y, 120 below the lowest piece and\n\t/// 800 above the highest; then into the cup-relative space when Relative Bounds is on.\n\t/// </summary>\n\t[Button( \"Fit to pieces\", \"fit_screen\" )]\n\tpublic void FitToPieces()\n\t{\n\t\tvar group = HoleGroup;\n\t\tBBox? box = null;\n\t\tvar count = 0;\n\t\tforeach ( var renderer in group.GetComponentsInChildren<ModelRenderer>( true ) )\n\t\t{\n\t\t\tif ( !renderer.IsValid() ) continue;\n\t\t\tvar b = renderer.Bounds;\n\t\t\tif ( b.Size.Length < 0.01f ) continue;\n\t\t\tbox = box is null ? b : box.Value.AddBBox( b );\n\t\t\tcount++;\n\t\t}\n\t\tif ( box is null )\n\t\t{\n\t\t\tLog.Warning( $\"Golf Hole: no pieces found under {group.Name} to fit the bounds to\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar world = new BBox( box.Value.Mins + new Vector3( -Margin, -Margin, -120f ), box.Value.Maxs + new Vector3( Margin, Margin, 800f ) );\n\t\tvar min = world.Mins;\n\t\tvar max = world.Maxs;\n\t\tif ( RelativeBounds )\n\t\t{\n\t\t\tmin -= WorldPosition;\n\t\t\tmax -= WorldPosition;\n\t\t}\n\t\tChangeBounds( \"Fit hole bounds to pieces\", min, max );\n\t\tLog.Info( $\"Golf Hole: {GameObject.Name} bounds fitted to {count} renderers under {group.Name}: world {world.Mins} .. {world.Maxs}{( RelativeBounds ? $\", relative {min} .. {max}\" : \"\" )}\" );\n\t}\n\n\t// A handle drag: the world box when it started (for the log and a clean undo step), the box\n\t// as dragged before any grid snapping (so snapping never swallows small moves), and the open\n\t// undo scope, from the first change until the mouse is released.\n\tBBox? _dragStart;\n\tBBox? _dragBox;\n\tSystem.IDisposable _dragUndo;\n\n\t/// <summary>\n\t/// Moves one face of the world box by a distance along its outward normal and writes the\n\t/// result back (cup-relative when Relative Bounds is on). The distance is in world units: the\n\t/// handles hand over the mouse's movement along the arrow already mapped from screen to world\n\t/// at the handle's depth. With snapping the moved face lands on the grid; the other faces stay.\n\t/// Faces never cross: a face stops one unit short of its opposite.\n\t/// </summary>\n\tpublic void DragFace( Vector3 normal, float distance, bool snap = false, float gridSpacing = 0f )\n\t{\n\t\tvar box = _dragBox ?? WorldBounds;\n\t\tvar min = box.Mins;\n\t\tvar max = box.Maxs;\n\t\tvar outMin = min;\n\t\tvar outMax = max;\n\t\tfor ( var i = 0; i < 3; i++ )\n\t\t{\n\t\t\tif ( normal[i] > 0.5f )\n\t\t\t{\n\t\t\t\tmax[i] = MathF.Max( min[i] + 1f, max[i] + distance );\n\t\t\t\toutMax[i] = snap && gridSpacing > 0f ? MathF.Max( min[i] + 1f, max[i].SnapToGrid( gridSpacing ) ) : max[i];\n\t\t\t}\n\t\t\telse if ( normal[i] < -0.5f )\n\t\t\t{\n\t\t\t\tmin[i] = MathF.Min( max[i] - 1f, min[i] - distance );\n\t\t\t\toutMin[i] = snap && gridSpacing > 0f ? MathF.Min( max[i] - 1f, min[i].SnapToGrid( gridSpacing ) ) : min[i];\n\t\t\t}\n\t\t}\n\t\t_dragBox = new BBox( min, max );\n\t\tSetWorldBounds( new BBox( outMin, outMax ) );\n\t}\n\n\tstatic readonly Vector3[] FaceNormals = { Vector3.Forward, Vector3.Backward, Vector3.Left, Vector3.Right, Vector3.Up, Vector3.Down };\n\n\t/// <summary>\n\t/// Editor: the out-of-bounds box as a translucent red wireframe while the cup is selected, with\n\t/// an arrow handle on each face. The handles are the engine's arrow control, which hands back\n\t/// the mouse movement along the arrow each frame; that movement is applied to the live bounds,\n\t/// so nothing depends on another control's state. The box is world-aligned, so everything is\n\t/// done in a world-space scope. The handles keep a fixed screen size.\n\t/// </summary>\n\tprotected override void DrawGizmos()\n\t{\n\t\tif ( !Gizmo.Pressed.Any && _dragUndo is not null )\n\t\t{\n\t\t\tLog.Info( $\"Golf Hole: {GameObject.Name} bounds drag ended: {( _dragStart is { } s ? $\"{s.Mins} .. {s.Maxs}\" : \"?\" )} -> {WorldBounds.Mins} .. {WorldBounds.Maxs} (min {BoundsMin}, max {BoundsMax})\" );\n\t\t\t_dragUndo.Dispose();\n\t\t\t_dragUndo = null;\n\t\t\t_dragStart = null;\n\t\t\t_dragBox = null;\n\t\t}\n\n\t\tif ( !Gizmo.IsSelected ) return;\n\n\t\tusing var scope = Gizmo.Scope( \"hole-bounds\" );\n\t\tGizmo.Transform = global::Transform.Zero;\n\n\t\tvar box = WorldBounds;\n\t\tGizmo.Draw.IgnoreDepth = false;\n\t\tGizmo.Draw.LineThickness = 2f;\n\t\tGizmo.Draw.Color = Color.Red.WithAlpha( 0.6f );\n\t\tGizmo.Draw.LineBBox( box );\n\t\tGizmo.Draw.IgnoreDepth = true;\n\t\tGizmo.Draw.Color = Color.Red.WithAlpha( 0.08f );\n\t\tGizmo.Draw.LineBBox( box );\n\n\t\tvar centre = box.Center;\n\t\tvar half = box.Size * 0.5f;\n\t\tfor ( var i = 0; i < FaceNormals.Length; i++ )\n\t\t{\n\t\t\tvar normal = FaceNormals[i];\n\t\t\tvar faceCentre = centre + normal * Vector3.Dot( half, new Vector3( MathF.Abs( normal.x ), MathF.Abs( normal.y ), MathF.Abs( normal.z ) ) );\n\n\t\t\tusing var face = Gizmo.Scope( $\"face{i}\", faceCentre );\n\t\t\t// The handle keeps a fixed screen size; the arrow's distance comes from a plane trace\n\t\t\t// under the mouse, so it is already in world units (the engine's position control\n\t\t\t// uses it the same way, without rescaling).\n\t\t\tusing var fixedScale = Gizmo.GizmoControls.PushFixedScale();\n\n\t\t\tGizmo.Draw.Color = Color.Red.WithAlpha( 0.9f );\n\t\t\tif ( !Gizmo.Control.Arrow( \"drag\", normal, out var moved, 18f, 5f, 0f, 2f ) || moved == 0f ) continue;\n\n\t\t\tif ( _dragUndo is null )\n\t\t\t{\n\t\t\t\t_dragStart = box;\n\t\t\t\t_dragBox = box;\n\t\t\t\t_dragUndo = Scene.Editor?.UndoScope( \"Resize hole bounds\" ).WithComponentChanges( this ).Push();\n\t\t\t\tLog.Info( $\"Golf Hole: {GameObject.Name} bounds drag started on face {normal}: box {box.Mins} .. {box.Maxs}, first move {moved:0.##} world units\" );\n\t\t\t}\n\t\t\tvar snapping = Gizmo.Settings.SnapToGrid != Gizmo.IsCtrlPressed;\n\t\t\tDragFace( normal, moved, snapping, Gizmo.Settings.GridSpacing );\n\t\t}\n\t}\n}\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "GolfRotator.cs",
            "FileName": "GolfRotator.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Spins the object it sits on round one of its own axes at a steady rate, on every machine and\r\n/// in the editor too (nothing is networked: every machine turns it the same way from the same\r\n/// start, and a prop that is a little out of step between machines does not matter). Made for\r\n/// the windmill's blades, whose axle is their local Y.\r\n///\r\n/// With a Rigidbody on the same object (motion off, so the body is keyframed in play) the turn\r\n/// goes through the physics body with a matching angular velocity, and on the host the box\r\n/// colliders on the object also push golf balls by hand: a ball inside the sweep is kept awake,\r\n/// and a ball an arm box overlaps is given the arm's surface speed at the contact point, so\r\n/// resting balls get swatted instead of being passed through.\r\n/// </summary>\r\n[Title( \"Golf Rotator\" ), Icon( \"sync\" ), Category( \"Golf\" )]\r\npublic sealed class GolfRotator : Component, Component.ExecuteInEditor\r\n{\r\n\t/// <summary>The local axis to turn round. The windmill blades turn round their local Y.</summary>\r\n\t[Property]\r\n\tpublic Vector3 Axis { get; set; } = new Vector3( 0f, 1f, 0f );\r\n\r\n\t/// <summary>Turning speed in degrees per second; negative turns the other way.</summary>\r\n\t[Property, Title( \"Degrees Per Second\" ), Range( -360f, 360f )]\r\n\tpublic float DegreesPerSecond { get; set; } = 30f;\r\n\r\n\tRigidbody _body;\r\n\tBoxCollider[] _arms = Array.Empty<BoxCollider>();\r\n\tfloat _reach;\r\n\tbool _loggedContact;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_body = GetComponent<Rigidbody>( true );\r\n\t\t_arms = GetComponents<BoxCollider>( true ).ToArray();\r\n\t\t_reach = _arms.Length == 0 ? 0f : _arms.Max( a => a.Center.Length + a.Scale.Length * 0.5f );\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// In play with a physics body the fixed step below does the turning.\r\n\t\tif ( !Scene.IsEditor && _body.IsValid() ) return;\r\n\t\tTurn( Time.Delta );\r\n\t}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor || !_body.IsValid() ) return;\r\n\t\tvar next = Turn( Time.Delta );\r\n\t\tif ( next is null ) return;\r\n\r\n\t\t// The keyframed body is rotated with the velocity that gets it there this step, so a ball\r\n\t\t// in the blade's path is pushed rather than finding the blade somewhere else next step.\r\n\t\t_body.SmoothRotate( next.Value, Time.Delta, Time.Delta );\r\n\r\n\t\tif ( Networking.IsHost ) PushBalls();\r\n\t}\r\n\r\n\t/// <summary>Turns the object by one step and returns the new world rotation, or null when there is nothing to do.</summary>\r\n\tRotation? Turn( float seconds )\r\n\t{\r\n\t\tvar axis = Axis;\r\n\t\tif ( axis.IsNearlyZero() || DegreesPerSecond == 0f || seconds <= 0f ) return null;\r\n\t\tLocalRotation *= Rotation.FromAxis( axis.Normal, DegreesPerSecond * seconds );\r\n\t\treturn WorldRotation;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Host only. Every ball within the sweep is kept awake (a sleeping ball ignores a keyframed\r\n\t/// body sliding into it), and a ball that an arm box overlaps gets at least the arm's surface\r\n\t/// velocity along the contact normal and is lifted out of the box.\r\n\t/// </summary>\r\n\tvoid PushBalls()\r\n\t{\r\n\t\tif ( _arms.Length == 0 || DegreesPerSecond == 0f ) return;\r\n\r\n\t\tvar hub = WorldPosition;\r\n\t\tvar omega = WorldRotation * Axis.Normal * DegreesPerSecond.DegreeToRadian();\r\n\t\tvar transform = WorldTransform;\r\n\r\n\t\tforeach ( var ball in Scene.GetAllComponents<IGolfCourseBall>() )\r\n\t\t{\r\n\t\t\tif ( !ball.GameObject.IsValid() || ball.IsHoled || !ball.Rigidbody.IsValid() ) continue;\r\n\r\n\t\t\tvar pos = ball.WorldPosition;\r\n\t\t\tif ( pos.Distance( hub ) > _reach + ball.Radius + 4f ) continue;\r\n\r\n\t\t\tball.Rigidbody.Sleeping = false;\r\n\r\n\t\t\tforeach ( var arm in _arms )\r\n\t\t\t{\r\n\t\t\t\tif ( !arm.IsValid() || !arm.Enabled ) continue;\r\n\r\n\t\t\t\t// Closest point of the arm box to the ball's centre, in the box's own frame.\r\n\t\t\t\tvar local = transform.PointToLocal( pos ) - arm.Center;\r\n\t\t\t\tvar half = arm.Scale * 0.5f;\r\n\t\t\t\tvar clamped = new Vector3( Math.Clamp( local.x, -half.x, half.x ), Math.Clamp( local.y, -half.y, half.y ), Math.Clamp( local.z, -half.z, half.z ) );\r\n\t\t\t\tvar closest = transform.PointToWorld( clamped + arm.Center );\r\n\t\t\t\tvar gap = pos - closest;\r\n\t\t\t\tvar dist = gap.Length;\r\n\t\t\t\tif ( dist > ball.Radius ) continue;\r\n\r\n\t\t\t\tvar surface = Vector3.Cross( omega, closest - hub );\r\n\t\t\t\tvar normal = dist > 0.05f ? gap.Normal : surface.Normal;\r\n\t\t\t\tif ( normal.IsNearlyZero() ) continue;\r\n\r\n\t\t\t\t// Match the arm's speed into the ball; a ball already moving away is left alone.\r\n\t\t\t\tvar velocity = ball.Rigidbody.Velocity;\r\n\t\t\t\tvar relative = (velocity - surface).Dot( normal );\r\n\t\t\t\tif ( relative < 0f ) ball.Rigidbody.Velocity = velocity - normal * relative;\r\n\r\n\t\t\t\t// Lift it out of the box so the next step starts from a clean contact.\r\n\t\t\t\tif ( dist < ball.Radius - 0.1f ) ball.WorldPosition = pos + normal * (ball.Radius - dist);\r\n\r\n\t\t\t\tif ( !_loggedContact )\r\n\t\t\t\t{\r\n\t\t\t\t\t_loggedContact = true;\r\n\t\t\t\t\tLog.Info( $\"GolfRotator: {GameObject.Name} first touched {ball.OwnerName}'s ball at {closest}, arm surface speed {surface.Length:0}, ball {velocity.Length:0} -> {ball.Rigidbody.Velocity.Length:0} u/s\" );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "TrenchFire.cs",
            "FileName": "TrenchFire.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// Decorative machine-gun fire for the trench map: bursts of glowing tracers fly from a random\r\n/// point on the Start line to a random point on the End line, with a muzzle flash where they leave\r\n/// and a dust puff where they land, and a distant, softened shot sound for every round. Purely a\r\n/// show: nothing is traced against players or balls, nothing takes damage, nothing is networked.\r\n/// Every machine runs its own with its own random timing.\r\n///\r\n/// The two lines are each two child objects (Start A/B, End A/B) that you move with the normal\r\n/// move gizmo; they are created the first time the component is seen in the editor if missing.\r\n/// The lines and the handles are drawn as gizmos. The component's own Enabled checkbox turns the\r\n/// gun off. Small pool of tracers and flash lights, nothing allocated per shot but the engine's\r\n/// own sound handle and the surface's impact particle.\r\n/// </summary>\r\n[Title( \"Trench Fire\" ), Category( \"Golf\" ), Icon( \"whatshot\" )]\r\npublic sealed class TrenchFire : Component\r\n{\r\n\t/// <summary>One end of the line the tracers leave from.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"Start A\" )]\r\n\tpublic GameObject StartA { get; set; }\r\n\r\n\t/// <summary>The other end of the line the tracers leave from.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"Start B\" )]\r\n\tpublic GameObject StartB { get; set; }\r\n\r\n\t/// <summary>One end of the line the tracers land on.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"End A\" )]\r\n\tpublic GameObject EndA { get; set; }\r\n\r\n\t/// <summary>The other end of the line the tracers land on.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"End B\" )]\r\n\tpublic GameObject EndB { get; set; }\r\n\r\n\t/// <summary>Random scatter around the landing point, in units.</summary>\r\n\t[Property, Group( \"Lines\" ), Range( 0f, 300f )]\r\n\tpublic float Spread { get; set; } = 40f;\r\n\r\n\t/// <summary>Bursts per minute.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 0.1f, 60f ), Title( \"Rate (bursts per minute)\" )]\r\n\tpublic float Rate { get; set; } = 6f;\r\n\r\n\t/// <summary>Fewest shots in a burst.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1, 60 ), Title( \"Burst Length Min\" )]\r\n\tpublic int BurstMin { get; set; } = 8;\r\n\r\n\t/// <summary>Most shots in a burst.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1, 60 ), Title( \"Burst Length Max\" )]\r\n\tpublic int BurstMax { get; set; } = 15;\r\n\r\n\t/// <summary>Shots per second inside a burst.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1f, 30f )]\r\n\tpublic float ShotsPerSecond { get; set; } = 12f;\r\n\r\n\t/// <summary>How much the pause between bursts varies, 0 for a metronome, 1 for anything from no pause to double.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 0f, 1f )]\r\n\tpublic float PauseVariance { get; set; } = 0.5f;\r\n\r\n\t/// <summary>How fast a tracer flies, units per second.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 500f, 20000f )]\r\n\tpublic float TracerSpeed { get; set; } = 6000f;\r\n\r\n\t/// <summary>Colour of the tracer streak, the flash and its light.</summary>\r\n\t[Property, Group( \"Tracer\" )]\r\n\tpublic Color TracerColour { get; set; } = new Color( 1f, 0.55f, 0.15f );\r\n\r\n\t/// <summary>Length of the streak, in units.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 10f, 1000f )]\r\n\tpublic float TracerLength { get; set; } = 160f;\r\n\r\n\t/// <summary>Width of the streak at its bright end, in units.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 0.5f, 10f )]\r\n\tpublic float TracerWidth { get; set; } = 2.5f;\r\n\r\n\t/// <summary>How much brighter than the colour the streak glows.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 1f, 8f )]\r\n\tpublic float Glow { get; set; } = 3f;\r\n\r\n\t/// <summary>A short flash of light at the start point for every shot.</summary>\r\n\t[Property, Group( \"Effects\" )]\r\n\tpublic bool MuzzleFlash { get; set; } = true;\r\n\r\n\t/// <summary>The surface's bullet impact particle where a tracer lands.</summary>\r\n\t[Property, Group( \"Effects\" )]\r\n\tpublic bool DustPuff { get; set; } = true;\r\n\r\n\t/// <summary>The distant shot, played at the start point for every round. Empty uses sounds/trench_fire.</summary>\r\n\t[Property, Group( \"Sound\" )]\r\n\tpublic SoundEvent ShotSound { get; set; }\r\n\r\n\t/// <summary>Volume of the shots, 0 to 1.</summary>\r\n\t[Property, Group( \"Sound\" ), Range( 0f, 1f )]\r\n\tpublic float Volume { get; set; } = 0.35f;\r\n\r\n\tconst string DefaultSound = \"sounds/trench_fire.sound\";\r\n\tconst float FlashSeconds = 0.05f;\r\n\tconst float FlashRadius = 160f;\r\n\r\n\tstruct Landing\r\n\t{\r\n\t\tpublic float At;        // Time.Now the tracer arrives; 0 for none pending\r\n\t\tpublic Vector3 Position;\r\n\t\tpublic Vector3 Normal;\r\n\t\tpublic Surface Surface;\r\n\t}\r\n\r\n\tTracerEffect[] _tracers = Array.Empty<TracerEffect>();\r\n\tLanding[] _landings = Array.Empty<Landing>();\r\n\tPointLight[] _flashes = Array.Empty<PointLight>();\r\n\tfloat[] _flashOff = Array.Empty<float>();\r\n\tint _nextTracer;\r\n\tint _nextFlash;\r\n\tSurface _defaultSurface;\r\n\tColor _builtColour;\r\n\tfloat _builtWidth;\r\n\r\n\tfloat _nextBurstAt;\r\n\tfloat _nextShotAt;\r\n\tint _shotsLeft;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tEnsureHandles();\r\n\t\tShotSound ??= ResourceLibrary.Get<SoundEvent>( DefaultSound );\r\n\t\t_defaultSurface = Surface.FindByName( \"default\" );\r\n\t\tBuildPool();\r\n\t\t_nextBurstAt = Time.Now + NextPause() * Game.Random.Float( 0f, 1f );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\t_shotsLeft = 0;\r\n\t\tfor ( var i = 0; i < _landings.Length; i++ ) _landings[i].At = 0f;\r\n\t\tforeach ( var flash in _flashes ) if ( flash.IsValid() ) flash.Enabled = false;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor ) return;\r\n\t\tif ( _tracers.Length == 0 ) return;\r\n\r\n\t\tvar now = Time.Now;\r\n\r\n\t\t// Flashes go out, landings puff.\r\n\t\tfor ( var i = 0; i < _flashes.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( _flashOff[i] > 0f && now >= _flashOff[i] )\r\n\t\t\t{\r\n\t\t\t\t_flashOff[i] = 0f;\r\n\t\t\t\tif ( _flashes[i].IsValid() ) _flashes[i].Enabled = false;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tfor ( var i = 0; i < _landings.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( _landings[i].At <= 0f || now < _landings[i].At ) continue;\r\n\t\t\t_landings[i].At = 0f;\r\n\t\t\tif ( DustPuff )\r\n\t\t\t\tBaseCombatWeapon.ImpactPrefab( null, _landings[i].Surface, _landings[i].Position, _landings[i].Normal );\r\n\t\t}\r\n\r\n\t\t// Bursts.\r\n\t\tif ( _shotsLeft <= 0 )\r\n\t\t{\r\n\t\t\tif ( now < _nextBurstAt ) return;\r\n\t\t\t_shotsLeft = Game.Random.Int( Math.Min( BurstMin, BurstMax ), Math.Max( BurstMin, BurstMax ) );\r\n\t\t\t_nextShotAt = now;\r\n\t\t\t_nextBurstAt = now + NextPause();\r\n\t\t}\r\n\r\n\t\tif ( now < _nextShotAt ) return;\r\n\t\t_nextShotAt = now + 1f / Math.Max( 1f, ShotsPerSecond );\r\n\t\t_shotsLeft--;\r\n\t\tFire();\r\n\t}\r\n\r\n\tfloat NextPause()\r\n\t{\r\n\t\tvar mean = 60f / Math.Max( 0.1f, Rate );\r\n\t\treturn mean * (1f + Game.Random.Float( -PauseVariance, PauseVariance ));\r\n\t}\r\n\r\n\tvoid Fire()\r\n\t{\r\n\t\tif ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;\r\n\r\n\t\tvar start = Vector3.Lerp( StartA.WorldPosition, StartB.WorldPosition, Game.Random.Float( 0f, 1f ) );\r\n\t\tvar end = Vector3.Lerp( EndA.WorldPosition, EndB.WorldPosition, Game.Random.Float( 0f, 1f ) )\r\n\t\t\t+ Vector3.Random * Spread;\r\n\r\n\t\t// Where it lands: the ground near the end point, for the puff's surface and facing.\r\n\t\tvar landing = new Landing { Position = end, Normal = Vector3.Up, Surface = _defaultSurface };\r\n\t\tvar trace = Scene.Trace.Ray( end + Vector3.Up * 48f, end + Vector3.Down * 96f ).WithoutTags( \"player\", \"golfball\" ).Run();\r\n\t\tif ( trace.Hit )\r\n\t\t{\r\n\t\t\tlanding.Position = trace.HitPosition;\r\n\t\t\tlanding.Normal = trace.Normal;\r\n\t\t\tlanding.Surface = trace.Surface ?? _defaultSurface;\r\n\t\t}\r\n\r\n\t\tvar distance = start.Distance( end );\r\n\t\tlanding.At = Time.Now + distance / Math.Max( 1f, TracerSpeed );\r\n\r\n\t\t// Tracer: the pool's oldest one restarts from the new start point.\r\n\t\tvar i = _nextTracer;\r\n\t\t_nextTracer = (_nextTracer + 1) % _tracers.Length;\r\n\t\tvar tracer = _tracers[i];\r\n\t\tif ( tracer.IsValid() )\r\n\t\t{\r\n\t\t\tif ( _builtColour != TracerColour || _builtWidth != TracerWidth ) BuildLook();\r\n\t\t\ttracer.Enabled = false;\r\n\t\t\ttracer.WorldPosition = start;\r\n\t\t\ttracer.EndPoint = end;\r\n\t\t\ttracer.DistancePerSecond = TracerSpeed;\r\n\t\t\ttracer.Length = TracerLength;\r\n\t\t\ttracer.Enabled = true;\r\n\t\t}\r\n\t\t_landings[i] = landing;\r\n\r\n\t\t// Muzzle flash.\r\n\t\tif ( MuzzleFlash && _flashes.Length > 0 )\r\n\t\t{\r\n\t\t\tvar f = _nextFlash;\r\n\t\t\t_nextFlash = (_nextFlash + 1) % _flashes.Length;\r\n\t\t\tvar flash = _flashes[f];\r\n\t\t\tif ( flash.IsValid() )\r\n\t\t\t{\r\n\t\t\t\tflash.WorldPosition = start + (end - start).Normal * 12f;\r\n\t\t\t\tflash.LightColor = TracerColour * Glow;\r\n\t\t\t\tflash.Radius = FlashRadius;\r\n\t\t\t\tflash.Enabled = true;\r\n\t\t\t\t_flashOff[f] = Time.Now + FlashSeconds;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// The distant shot.\r\n\t\tif ( ShotSound is not null && Volume > 0f )\r\n\t\t{\r\n\t\t\tvar handle = Sound.Play( ShotSound, start );\r\n\t\t\tif ( handle.IsValid() ) handle.Volume = Volume;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Enough tracers to cover the longest flight at the burst rate, and a couple of flash lights.\r\n\t/// Pool objects are hidden children that are never saved or networked.\r\n\t/// </summary>\r\n\tvoid BuildPool()\r\n\t{\r\n\t\tvar longest = 0f;\r\n\t\tif ( StartA.IsValid() && StartB.IsValid() && EndA.IsValid() && EndB.IsValid() )\r\n\t\t{\r\n\t\t\tlongest = Math.Max( Math.Max( StartA.WorldPosition.Distance( EndA.WorldPosition ), StartA.WorldPosition.Distance( EndB.WorldPosition ) ),\r\n\t\t\t\tMath.Max( StartB.WorldPosition.Distance( EndA.WorldPosition ), StartB.WorldPosition.Distance( EndB.WorldPosition ) ) );\r\n\t\t}\r\n\t\tvar flight = (longest + Spread + TracerLength) / Math.Max( 1f, TracerSpeed );\r\n\t\tvar count = Math.Clamp( (int)MathF.Ceiling( flight * ShotsPerSecond ) + 2, 4, 32 );\r\n\r\n\t\t_tracers = new TracerEffect[count];\r\n\t\t_landings = new Landing[count];\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tvar go = new GameObject( GameObject, false, $\"tracer {i}\" );\r\n\t\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;\r\n\t\t\tvar tracer = go.AddComponent<TracerEffect>( false );\r\n\t\t\ttracer.StartDistance = 0f;\r\n\t\t\ttracer.Opaque = false;\r\n\t\t\ttracer.CastShadows = false;\r\n\t\t\ttracer.EnableLight = false;\r\n\t\t\t_tracers[i] = tracer;\r\n\t\t\tgo.Enabled = true;\r\n\t\t}\r\n\t\tBuildLook();\r\n\r\n\t\t_flashes = new PointLight[2];\r\n\t\t_flashOff = new float[2];\r\n\t\tfor ( var i = 0; i < _flashes.Length; i++ )\r\n\t\t{\r\n\t\t\tvar go = new GameObject( GameObject, false, $\"flash {i}\" );\r\n\t\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;\r\n\t\t\tvar light = go.AddComponent<PointLight>( false );\r\n\t\t\tlight.Shadows = false;\r\n\t\t\tlight.Radius = FlashRadius;\r\n\t\t\tlight.Attenuation = 2f;\r\n\t\t\t_flashes[i] = light;\r\n\t\t\tgo.Enabled = true;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid BuildLook()\r\n\t{\r\n\t\t_builtColour = TracerColour;\r\n\t\t_builtWidth = TracerWidth;\r\n\t\tvar head = TracerColour * Glow;\r\n\t\thead.a = 1f;\r\n\t\tvar colour = new Gradient( new Gradient.ColorFrame( 0f, head ), new Gradient.ColorFrame( 0.35f, TracerColour ), new Gradient.ColorFrame( 1f, TracerColour.WithAlpha( 0f ) ) );\r\n\t\tvar width = new Curve( new Curve.Frame( 0f, TracerWidth ), new Curve.Frame( 1f, 0f ) );\r\n\t\tforeach ( var tracer in _tracers )\r\n\t\t{\r\n\t\t\tif ( !tracer.IsValid() ) continue;\r\n\t\t\ttracer.LineColor = colour;\r\n\t\t\ttracer.LineWidth = width;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The four handle objects: found by name under this object, or made with default spots.\r\n\t/// </summary>\r\n\tvoid EnsureHandles()\r\n\t{\r\n\t\tStartA = Handle( StartA, \"Start A\", new Vector3( 0f, -150f, 40f ) );\r\n\t\tStartB = Handle( StartB, \"Start B\", new Vector3( 0f, 150f, 40f ) );\r\n\t\tEndA = Handle( EndA, \"End A\", new Vector3( 1200f, -250f, 0f ) );\r\n\t\tEndB = Handle( EndB, \"End B\", new Vector3( 1200f, 250f, 0f ) );\r\n\t}\r\n\r\n\tGameObject Handle( GameObject current, string name, Vector3 localPosition )\r\n\t{\r\n\t\tif ( current.IsValid() ) return current;\r\n\r\n\t\tforeach ( var child in GameObject.Children )\r\n\t\t\tif ( child.IsValid() && child.Name == name ) return child;\r\n\r\n\t\tvar go = new GameObject( GameObject, true, name );\r\n\t\tgo.LocalPosition = localPosition;\r\n\t\treturn go;\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tEnsureHandles();\r\n\t\tif ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;\r\n\r\n\t\tvar alpha = Gizmo.IsSelected ? 1f : (Gizmo.IsHovered ? 0.6f : 0.3f);\r\n\t\tvar local = WorldTransform;\r\n\t\tvar sa = local.PointToLocal( StartA.WorldPosition );\r\n\t\tvar sb = local.PointToLocal( StartB.WorldPosition );\r\n\t\tvar ea = local.PointToLocal( EndA.WorldPosition );\r\n\t\tvar eb = local.PointToLocal( EndB.WorldPosition );\r\n\r\n\t\tGizmo.Draw.LineThickness = Gizmo.IsSelected ? 3f : 2f;\r\n\t\tGizmo.Draw.Color = TracerColour.WithAlpha( alpha );\r\n\t\tGizmo.Draw.Line( sa, sb );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( sa, 8f ), 8 );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( sb, 8f ), 8 );\r\n\r\n\t\tGizmo.Draw.Color = Color.Red.WithAlpha( alpha );\r\n\t\tGizmo.Draw.Line( ea, eb );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( ea, 8f ), 8 );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( eb, 8f ), 8 );\r\n\r\n\t\tGizmo.Draw.LineThickness = 1f;\r\n\t\tGizmo.Draw.Color = Color.White.WithAlpha( alpha * 0.7f );\r\n\t\tGizmo.Draw.Arrow( (sa + sb) * 0.5f, (ea + eb) * 0.5f, 24f, 8f );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Editor/KitWallBuilderTool.cs",
            "FileName": "KitWallBuilderTool.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing Editor;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Kit Wall Builder: a Scene view tool that lays walls between posts, like Golf With Your Friends'\r\n/// Easy Wall Builder. Click a surface to drop a post; every further click drops the next post and\r\n/// fills a wall between the two, any angle, any length. On a slope a kit wall is a parallelogram\r\n/// (vertical ends, edges along the ground line); a model wall is sheared the same way (Kit Model\r\n/// Wall, a copy of the model's mesh). Right-click or Esc ends the chain; Shift-click afterwards\r\n/// continues from the last post. Pieces go under the selected Hole group (or the scene root), each\r\n/// click with its own undo step.\r\n///\r\n/// The sidebar takes a Wall Model and a Post Model (any model asset, built along its X axis). With\r\n/// no model set, the wall is the hole kit's wall prefab (a Kit Shape with its Length set) and the\r\n/// post a 10 x 10 x 30 kit wall piece. A model gives a plain object with a Model Renderer and a\r\n/// Model Collider, keeping the model's own materials. Fit: Stretch scales one copy to the run,\r\n/// Repeat lays whole copies and trims the last one by scale.\r\n///\r\n/// Collision: Golf Hole Collision merges kit pieces only; a model wall keeps its own Model Collider,\r\n/// which is fine for a wall (the merge exists for floor seams).\r\n/// </summary>\r\n[EditorTool( \"kitwall\" )]\r\n[Title( \"Kit Wall Builder\" )]\r\n[Icon( \"fence\" )]\r\npublic sealed class KitWallBuilderTool : EditorTool\r\n{\r\n\tpublic enum WallFit\r\n\t{\r\n\t\t/// <summary>One copy of the wall model, scaled along its length to fit between the posts.</summary>\r\n\t\tStretch,\r\n\r\n\t\t/// <summary>Whole copies of the wall model along the run; the last one is trimmed by scale.</summary>\r\n\t\tRepeat,\r\n\t}\r\n\r\n\tconst string WallPrefab = \"prefabs/holekit/wall.prefab\";\r\n\tconst float PostSize = KitShape.WallThickness;\r\n\r\n\t/// <summary>Model for the walls; empty means the hole kit wall.</summary>\r\n\t[Property, Title( \"Wall Model\" )]\r\n\tpublic Model WallModel { get; set; }\r\n\r\n\t/// <summary>Model for the posts; empty means a 10 x 10 x 30 kit wall piece.</summary>\r\n\t[Property, Title( \"Post Model\" )]\r\n\tpublic Model PostModel { get; set; }\r\n\r\n\t/// <summary>How a wall model fills the run between two posts.</summary>\r\n\t[Property, Title( \"Fit\" )]\r\n\tpublic WallFit Fit { get; set; } = WallFit.Stretch;\r\n\r\n\t/// <summary>A click this close to a post (in units) uses that post instead of placing a new one.</summary>\r\n\t[Property, Title( \"Snap Distance\" ), Range( 1f, 200f )]\r\n\tpublic float SnapDistance { get; set; } = KitShape.WallThickness;\r\n\r\n\tVector3? _lastPost;\r\n\tbool _chainOpen;\r\n\treadonly System.Collections.Generic.List<Vector3> _chainPosts = new();\r\n\tEditor.Label _status;\r\n\r\n\tpublic override void OnEnabled()\r\n\t{\r\n\t\t// A course whose Golf Course Info says Minigolf gets the hedge set: its wall between posts and\r\n\t\t// its corner piece as the post. Elsewhere the Blender-made kit models, when they exist, then the\r\n\t\t// kit prefab. Model.Load reads them from disk; ResourceLibrary only knows models something already loaded.\r\n\t\tif ( GolfCourseInfo.Find( SceneEditorSession.Active?.Scene )?.WallStyle == GolfCourseInfo.KitWallStyle.Minigolf )\r\n\t\t{\r\n\t\t\tWallModel ??= LoadKitModel( \"models/kit/minigolf/minigolf_wall.vmdl\" );\r\n\t\t\tPostModel ??= LoadKitModel( \"models/kit/minigolf/minigolf_corner.vmdl\" );\r\n\t\t}\r\n\t\tWallModel ??= LoadKitModel( \"models/kit/wall_100.vmdl\" );\r\n\t\tPostModel ??= LoadKitModel( \"models/kit/post_10.vmdl\" );\r\n\t}\r\n\r\n\tinternal static Model LoadKitModel( string path )\r\n\t{\r\n\t\tvar model = Model.Load( path );\r\n\t\treturn model is null || model.IsError ? null : model;\r\n\t}\r\n\r\n\tpublic override Widget CreateToolSidebar()\r\n\t{\r\n\t\tvar sidebar = new ToolSidebarWidget();\r\n\t\tsidebar.AddTitle( \"Kit Wall Builder\", \"fence\" );\r\n\t\tsidebar.MinimumWidth = 320;\r\n\r\n\t\tvar models = sidebar.AddGroup( \"Models\" );\r\n\t\tvar so = this.GetSerialized();\r\n\t\tmodels.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( WallModel ) ) ) );\r\n\t\tmodels.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( PostModel ) ) ) );\r\n\t\tmodels.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( Fit ) ) ) );\r\n\t\tmodels.Add( Editor.ControlSheet.CreateRow( so.GetProperty( nameof( SnapDistance ) ) ) );\r\n\t\tmodels.Add( new Editor.Label( \"Empty slots use the hole kit wall. A wall model is built along its X axis.\" ) );\r\n\r\n\t\tvar how = sidebar.AddGroup( \"How to use\" );\r\n\t\thow.Add( new Editor.Label( \"Click a surface: a post.\\nNext click: a post and a wall between.\\nClick an existing post: the wall joins it (the first post closes the loop).\\nRight-click or Esc: end the chain.\\nShift-click: continue from the last post.\\nCtrl+Z: remove the last click.\" ) );\r\n\r\n\t\tvar where = sidebar.AddGroup( \"Pieces go under\" );\r\n\t\t_status = new Editor.Label( ParentName() );\r\n\t\twhere.Add( _status );\r\n\r\n\t\treturn sidebar;\r\n\t}\r\n\r\n\tpublic override void OnDisabled()\r\n\t{\r\n\t\t_lastPost = null;\r\n\t\t_chainOpen = false;\r\n\t\t_chainPosts.Clear();\r\n\t}\r\n\r\n\tpublic override void OnUpdate()\r\n\t{\r\n\t\tif ( _status.IsValid() ) _status.Text = ParentName();\r\n\r\n\t\t// Esc ends the chain; so does a right-click.\r\n\t\tif ( Editor.Application.IsKeyDown( KeyCode.Escape ) || Gizmo.WasRightMousePressed )\r\n\t\t\t_chainOpen = false;\r\n\r\n\t\t// The whole view is ours while the tool is active, so clicks don't pick objects.\r\n\t\tGizmo.Hitbox.BBox( BBox.FromPositionAndSize( Vector3.Zero, 999999 ) );\r\n\r\n\t\tvar trace = Scene.Trace.Ray( Gizmo.CurrentRay, 100000 ).UseRenderMeshes( true ).Run();\r\n\t\tif ( !trace.Hit ) return;\r\n\r\n\t\tvar parent = FindParent();\r\n\t\tvar point = trace.HitPosition;\r\n\t\tvar continuing = _lastPost.HasValue && (_chainOpen || Gizmo.IsShiftPressed);\r\n\r\n\t\t// Near an existing post: use it exactly, so a loop closes on the post it started from.\r\n\t\tvar snapped = NearestPost( parent, point );\r\n\t\tif ( snapped.HasValue ) point = snapped.Value;\r\n\r\n\t\tDrawPreview( point, continuing, snapped.HasValue );\r\n\r\n\t\tif ( !Gizmo.WasLeftMousePressed ) return;\r\n\r\n\t\tif ( snapped.HasValue )\r\n\t\t{\r\n\t\t\tif ( !continuing || _lastPost.Value.Distance( point ) <= PostSize ) return;   // the post we are standing on\r\n\t\t\tPlaceWall( parent, _lastPost.Value, point );\r\n\t\t\tvar closedLoop = _chainPosts.Count > 0 && _chainPosts[0].Distance( point ) <= SnapDistance;\r\n\t\t\t_lastPost = point;\r\n\t\t\t_chainOpen = !closedLoop;\r\n\t\t\tif ( closedLoop ) _chainPosts.Clear();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( continuing && _lastPost.Value.Distance( point ) > PostSize )\r\n\t\t\tPlaceWall( parent, _lastPost.Value, point );\r\n\r\n\t\tPlacePost( parent, point );\r\n\t\tif ( !continuing ) _chainPosts.Clear();\r\n\t\t_chainPosts.Add( point );\r\n\t\t_lastPost = point;\r\n\t\t_chainOpen = true;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The base of the nearest post within the snap distance: posts of this chain first, then every\r\n\t/// object named Post under the group the pieces go to. Null when none is close.\r\n\t/// </summary>\r\n\tVector3? NearestPost( GameObject parent, Vector3 point )\r\n\t{\r\n\t\tVector3? best = null;\r\n\t\tvar bestDistance = SnapDistance;\r\n\r\n\t\tvoid Consider( Vector3 basePoint )\r\n\t\t{\r\n\t\t\tvar distance = basePoint.Distance( point );\r\n\t\t\tif ( distance <= bestDistance ) { bestDistance = distance; best = basePoint; }\r\n\t\t}\r\n\r\n\t\tforeach ( var post in _chainPosts ) Consider( post );\r\n\t\tif ( _lastPost.HasValue ) Consider( _lastPost.Value );\r\n\r\n\t\tvar children = parent.IsValid() ? parent.Children : Scene.Children;\r\n\t\tforeach ( var go in children )\r\n\t\t{\r\n\t\t\tif ( !go.IsValid() || go.Name != \"Post\" ) continue;\r\n\t\t\tvar basePoint = go.WorldPosition;\r\n\t\t\tif ( go.GetComponent<KitShape>() is null && go.GetComponent<ModelRenderer>() is { Model: { } model } )\r\n\t\t\t\tbasePoint += new Vector3( model.Bounds.Center.x, model.Bounds.Center.y, model.Bounds.Mins.z );\r\n\t\t\tConsider( basePoint );\r\n\t\t}\r\n\t\treturn best;\r\n\t}\r\n\r\n\t/// <summary>The selected Hole group (the selection or one of its parents), else the scene root.</summary>\r\n\tGameObject FindParent()\r\n\t{\r\n\t\tforeach ( var selected in Selection.OfType<GameObject>() )\r\n\t\t{\r\n\t\t\tfor ( var go = selected; go.IsValid(); go = go.Parent )\r\n\t\t\t{\r\n\t\t\t\tif ( go.GetComponent<GolfHoleCollision>() is not null || go.Name.StartsWith( \"Hole \", StringComparison.OrdinalIgnoreCase ) )\r\n\t\t\t\t\treturn go;\r\n\t\t\t}\r\n\t\t}\r\n\t\treturn null;\r\n\t}\r\n\r\n\tstring ParentName()\r\n\t{\r\n\t\tvar parent = FindParent();\r\n\t\treturn parent.IsValid() ? parent.Name : \"the scene root (select a Hole group to build into it)\";\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- placing\r\n\r\n\tvoid PlacePost( GameObject parent, Vector3 point )\r\n\t{\r\n\t\tvar session = SceneEditorSession.Active;\r\n\t\tif ( session is null ) return;\r\n\t\tusing var sceneScope = session.Scene.Push();\r\n\t\tusing var undo = session.UndoScope( \"Wall builder post\" ).WithGameObjectCreations().Push();\r\n\r\n\t\tif ( PostModel is null )\r\n\t\t{\r\n\t\t\tvar go = GameObject.Clone( WallPrefab, new Transform( point, Rotation.Identity ), parent, true, \"Post\" );\r\n\t\t\tvar shape = go?.GetComponent<KitShape>();\r\n\t\t\tif ( shape is not null ) { shape.Length = PostSize; return; }\r\n\r\n\t\t\t// The kit wall is a model piece: stand it with its bottom centre on the click point.\r\n\t\t\tvar prefabModel = go?.GetComponent<ModelRenderer>()?.Model;\r\n\t\t\tif ( prefabModel is not null ) go.WorldPosition = point - BottomCentre( prefabModel.Bounds );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// The model's bottom centre on the click point.\r\n\t\tvar bounds = PostModel.Bounds;\r\n\t\tvar bottomCentre = new Vector3( bounds.Center.x, bounds.Center.y, bounds.Mins.z );\r\n\t\tCreateModelObject( parent, \"Post\", PostModel, point - bottomCentre, Rotation.Identity, Vector3.One );\r\n\t}\r\n\r\n\tvoid PlaceWall( GameObject parent, Vector3 from, Vector3 to )\r\n\t{\r\n\t\tvar session = SceneEditorSession.Active;\r\n\t\tif ( session is null ) return;\r\n\t\tusing var sceneScope = session.Scene.Push();\r\n\t\tusing var undo = session.UndoScope( \"Wall builder wall\" ).WithGameObjectCreations().Push();\r\n\r\n\t\t// Along the slope from post base to post base, roll kept level.\r\n\t\tvar run = to - from;\r\n\t\tvar length = run.Length;\r\n\t\tif ( length < 1f ) return;\r\n\t\tvar rotation = Rotation.LookAt( run.Normal, Vector3.Up );\r\n\r\n\t\tif ( WallModel is null )\r\n\t\t{\r\n\t\t\t// A kit wall is a parallelogram: level yaw, horizontal length, and the height difference\r\n\t\t\t// built into the mesh as Rise, so both ends stand vertical on their post bases.\r\n\t\t\tvar flat = run.WithZ( 0f );\r\n\t\t\tif ( flat.Length < 1f ) return;\r\n\t\t\tvar go = GameObject.Clone( WallPrefab, new Transform( (from + to) * 0.5f, Rotation.LookAt( flat.Normal, Vector3.Up ) ), parent, true, \"Wall\" );\r\n\t\t\tvar shape = go?.GetComponent<KitShape>();\r\n\t\t\tif ( shape is not null )\r\n\t\t\t{\r\n\t\t\t\tshape.Length = flat.Length;\r\n\t\t\t\tshape.Rise = run.z;\r\n\t\t\t\treturn;\r\n\t\t\t}\r\n\r\n\t\t\t// The kit wall is a model piece: stretch it between the posts like a model wall.\r\n\t\t\tvar prefabModel = go?.GetComponent<ModelRenderer>()?.Model;\r\n\t\t\tif ( prefabModel is not null ) FitModelObject( go, prefabModel, from, rotation, 0f, length );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar modelLength = ModelLength( WallModel.Bounds );\r\n\r\n\t\tif ( Fit == WallFit.Stretch )\r\n\t\t{\r\n\t\t\tPlaceWallPiece( parent, WallModel, from, to );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t// Repeat: whole copies along the run, then the remainder trimmed by scale.\r\n\t\tvar direction = run.Normal;\r\n\t\tvar copies = (int)MathF.Floor( length / modelLength );\r\n\t\tfor ( var i = 0; i < copies; i++ )\r\n\t\t\tPlaceWallPiece( parent, WallModel, from + direction * (i * modelLength), from + direction * ((i + 1) * modelLength) );\r\n\r\n\t\tvar rest = length - copies * modelLength;\r\n\t\tif ( rest > 1f )\r\n\t\t\tPlaceWallPiece( parent, WallModel, from + direction * (copies * modelLength), to );\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One wall model piece from one point to another, always a Kit Model Wall: the model's mesh\r\n\t/// rebuilt at the run's length (so the wall material repeats along the run instead of\r\n\t/// stretching with a scaled model) and, on a slope, sheared so the ends stay vertical and the\r\n\t/// top follows the ground line. Line Y is the model's local Y that sits on the line between\r\n\t/// the points (its bounds centre when not given). Returns the new object, or null for no run.\r\n\t/// </summary>\r\n\tinternal static GameObject PlaceWallPiece( GameObject parent, Model model, Vector3 from, Vector3 to, float? lineY = null )\r\n\t{\r\n\t\tvar run = to - from;\r\n\t\tvar flat = run.WithZ( 0f );\r\n\t\tif ( flat.Length < 1f ) return null;\r\n\t\tvar rotation = Rotation.LookAt( flat.Normal, Vector3.Up );\r\n\r\n\t\tvar wall = new GameObject( true, \"Wall\" );\r\n\t\tif ( parent.IsValid() ) wall.SetParent( parent, false );\r\n\t\twall.WorldPosition = from;\r\n\t\twall.WorldRotation = rotation;\r\n\t\tvar shear = wall.AddComponent<KitModelWall>();\r\n\t\tshear.Source = model;\r\n\t\tshear.Length = flat.Length;\r\n\t\tshear.Rise = run.z;\r\n\t\tshear.LineY = lineY ?? model.Bounds.Center.y;\r\n\t\tshear.Material = LoadWallMaterial();\r\n\t\tshear.Rebuild();\r\n\t\treturn wall;\r\n\t}\r\n\r\n\t/// <summary>The material on every wall and post the tools make (concrete; the floor is grass).</summary>\r\n\tinternal const string WallMaterialPath = \"textures/tcom_concrete_base8_2x2_1k.vmat\";\r\n\r\n\tinternal static Material LoadWallMaterial()\r\n\t{\r\n\t\tvar material = Material.Load( WallMaterialPath );\r\n\t\tif ( material is null ) Log.Warning( $\"Kit wall material {WallMaterialPath} not found; walls and posts keep the model's own material.\" );\r\n\t\treturn material;\r\n\t}\r\n\r\n\t/// <summary>Length of the model along X, the kit's wall axis, at least 1.</summary>\r\n\tstatic float ModelLength( BBox bounds ) => MathF.Max( 1f, bounds.Size.x );\r\n\r\n\t/// <summary>Bottom centre of the model's bounds, the point that stands on the floor.</summary>\r\n\tinternal static Vector3 BottomCentre( BBox bounds ) => new( bounds.Center.x, bounds.Center.y, bounds.Mins.z );\r\n\r\n\t/// <summary>\r\n\t/// Places a model object so it covers [start, start + length] along the run from the first post:\r\n\t/// its X axis (the kit's wall axis) along the run, scaled on X so its bounds span the length,\r\n\t/// Z up, with its bottom on the line between the post bases at the model's local Line Y (its\r\n\t/// bounds centre when not given).\r\n\t/// </summary>\r\n\tinternal static void FitModelObject( GameObject go, Model model, Vector3 from, Rotation rotation, float start, float length, float? lineY = null )\r\n\t{\r\n\t\tvar bounds = model.Bounds;\r\n\t\tvar scale = new Vector3( length / ModelLength( bounds ), 1f, 1f );\r\n\t\tvar segmentCentre = from + rotation.Forward * (start + length * 0.5f);\r\n\t\tvar onLine = new Vector3( bounds.Center.x, lineY ?? bounds.Center.y, bounds.Mins.z );\r\n\r\n\t\tgo.WorldRotation = rotation;\r\n\t\tgo.LocalScale = scale;\r\n\t\tgo.WorldPosition = segmentCentre - rotation * (onLine * scale);\r\n\t}\r\n\r\n\tinternal static GameObject CreateModelObject( GameObject parent, string name, Model model, Vector3 position, Rotation rotation, Vector3 scale )\r\n\t{\r\n\t\tvar go = new GameObject( true, name );\r\n\t\tif ( parent.IsValid() ) go.SetParent( parent, false );\r\n\t\tgo.WorldPosition = position;\r\n\t\tgo.WorldRotation = rotation;\r\n\t\tgo.LocalScale = scale;\r\n\r\n\t\tvar renderer = go.AddComponent<ModelRenderer>();\r\n\t\trenderer.Model = model;\r\n\t\trenderer.MaterialOverride = LoadWallMaterial();\r\n\r\n\t\tvar collider = go.AddComponent<ModelCollider>();\r\n\t\tcollider.Model = model;\r\n\t\tcollider.Static = true;\r\n\t\tcollider.Surface = Surface.FindByName( GolfHoleCollision.SurfaceName ) ?? collider.Surface;\r\n\t\treturn go;\r\n\t}\r\n\r\n\t// ---------------------------------------------------------------- preview\r\n\r\n\t/// <summary>\r\n\t/// A post outline at the cursor (cyan and thicker when it snaps to an existing post), and the\r\n\t/// wall footprint from the last post while a chain is on.\r\n\t/// </summary>\r\n\tvoid DrawPreview( Vector3 point, bool continuing, bool snapping )\r\n\t{\r\n\t\tGizmo.Draw.Color = snapping ? Color.Cyan : Color.Green;\r\n\t\tGizmo.Draw.LineThickness = snapping ? 4f : 2f;\r\n\r\n\t\tvar postBox = PostModel is null\r\n\t\t\t? new BBox( new Vector3( -PostSize / 2f, -PostSize / 2f, 0f ), new Vector3( PostSize / 2f, PostSize / 2f, KitShape.WallHeight ) )\r\n\t\t\t: new BBox( PostModel.Bounds.Mins - new Vector3( PostModel.Bounds.Center.x, PostModel.Bounds.Center.y, PostModel.Bounds.Mins.z ), PostModel.Bounds.Maxs - new Vector3( PostModel.Bounds.Center.x, PostModel.Bounds.Center.y, PostModel.Bounds.Mins.z ) );\r\n\t\tusing ( Gizmo.Scope( \"kitwall-post\", new Transform( point, Rotation.Identity ) ) )\r\n\t\t\tGizmo.Draw.LineBBox( postBox );\r\n\r\n\t\tif ( !continuing ) return;\r\n\r\n\t\tvar from = _lastPost.Value;\r\n\t\tvar run = point - from;\r\n\t\tvar length = run.Length;\r\n\t\tif ( length < 1f ) return;\r\n\r\n\t\tGizmo.Draw.Color = Color.Yellow;\r\n\t\tGizmo.Draw.LineThickness = 2f;\r\n\r\n\t\t// The parallelogram both wall kinds make: vertical ends, top and bottom along the ground line.\r\n\t\tvar flat = run.WithZ( 0f );\r\n\t\tif ( flat.Length < 1f ) return;\r\n\t\tvar yaw = Rotation.LookAt( flat.Normal, Vector3.Up );\r\n\t\tvar thickness = WallModel is null ? KitShape.WallThickness : MathF.Max( 1f, WallModel.Bounds.Size.y );\r\n\t\tvar height = WallModel is null ? KitShape.WallHeight : MathF.Max( 1f, WallModel.Bounds.Size.z );\r\n\t\tvar up = Vector3.Up * height;\r\n\t\tvar side = yaw.Left * (thickness / 2f);\r\n\t\tforeach ( var offset in new[] { side, -side } )\r\n\t\t{\r\n\t\t\tGizmo.Draw.Line( from + offset, point + offset );\r\n\t\t\tGizmo.Draw.Line( from + offset + up, point + offset + up );\r\n\t\t\tGizmo.Draw.Line( from + offset, from + offset + up );\r\n\t\t\tGizmo.Draw.Line( point + offset, point + offset + up );\r\n\t\t}\r\n\t\tGizmo.Draw.Line( from + side + up, from - side + up );\r\n\t\tGizmo.Draw.Line( point + side + up, point - side + up );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "GolfHoleCollision.cs",
            "FileName": "GolfHoleCollision.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Diagnostics;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// One welded collision mesh for a whole hole. Lives on the \"Hole N\" group (and the Kit Test Hole):\r\n/// collects every enabled Kit Shape piece under the group, takes their collision geometry in hole\r\n/// space, welds shared vertices across the pieces and builds a single static mesh collider with the\r\n/// golf surface. The pieces' own colliders are switched off, their visible meshes stay as they are.\r\n/// A ball rolling over a seam between two pieces then never meets an edge, so it never hops.\r\n///\r\n/// Runs in the editor too and rebuilds itself whenever a piece is added, moved, changed or removed,\r\n/// so a hole is built with pieces and this never needs touching. Nothing is networked: every machine\r\n/// builds the same mesh from the same scene (the piece set and their transforms come from the scene\r\n/// file, and welding is position based, so the result does not depend on order or on who is host).\r\n/// </summary>\r\npublic sealed class GolfHoleCollision : Component, Component.ExecuteInEditor\r\n{\r\n\t/// <summary>Physics surface for the whole hole (Assets/surface/golf_green.surface).</summary>\r\n\tpublic const string SurfaceName = \"golf_green\";\r\n\r\n\t/// <summary>Vertices closer than 1/16 unit are the same vertex. Rotated pieces (walls, corners,\r\n\t/// bumpers) land a hair off the grid, this snaps them back onto their neighbours.</summary>\r\n\tconst float WeldGrid = 16f;\r\n\r\n\t/// <summary>How often the pieces are checked for changes, in seconds.</summary>\r\n\tconst float CheckInterval = 0.25f;\r\n\r\n\t[Property, ReadOnly] public int Pieces { get; private set; }\r\n\t[Property, ReadOnly] public int Vertices { get; private set; }\r\n\t[Property, ReadOnly] public int Triangles { get; private set; }\r\n\r\n\t/// <summary>How many hole colliders have been rebuilt since load (see golf_perf).</summary>\r\n\tpublic static int RebuildCount;\r\n\r\n\tbool _dirty = true;\r\n\tint _lastHash;\r\n\tTimeSince _sinceCheck;\r\n\tModelCollider _collider;\r\n\r\n\t/// <summary>A piece under this hole changed: rebuild on the next update.</summary>\r\n\tpublic void MarkDirty() => _dirty = true;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\t_dirty = true;\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tif ( _collider.IsValid() ) _collider.Enabled = false;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( !_dirty )\r\n\t\t{\r\n\t\t\t// Polling for moved or edited pieces is for the editor. In play nothing moves a piece\r\n\t\t\t// except code, and that calls MarkDirty; every rebuild there cooks a physics mesh.\r\n\t\t\tif ( Game.IsPlaying ) return;\r\n\t\t\tif ( _sinceCheck < CheckInterval ) return;\r\n\t\t\t_sinceCheck = 0;\r\n\t\t\tif ( ComputeHash() == _lastHash ) return;\r\n\t\t}\r\n\r\n\t\tRebuild();\r\n\t}\r\n\r\n\tIEnumerable<KitShape> PiecesBelow() => Components.GetAll<KitShape>( FindMode.EnabledInSelfAndDescendants );\r\n\r\n\t/// <summary>Model pieces below (a Model Collider with a model, on an object that is not a Kit Shape): the Blender-made kit pieces.</summary>\r\n\t// Disabled colliders count too: the merge switches a piece's own collider off, and a saved scene\r\n\t// keeps it off, so the piece must still be found on the next load.\r\n\tIEnumerable<ModelCollider> ModelPiecesBelow() => Components.GetAll<ModelCollider>( FindMode.EverythingInSelfAndDescendants )\r\n\t\t.Where( c => c.IsValid() && c.GameObject != GameObject && c.GameObject.Enabled && c.Model is not null && c.GetComponent<KitShape>() is null );\r\n\r\n\t/// <summary>Cheap fingerprint of every piece's transform and settings, for the change check.</summary>\r\n\tint ComputeHash()\r\n\t{\r\n\t\tvar hash = new HashCode();\r\n\t\thash.Add( 2 ); // bump to force a rebuild after a code change\r\n\t\tvar hole = WorldTransform;\r\n\t\tforeach ( var piece in PiecesBelow() )\r\n\t\t{\r\n\t\t\t// Pieces without collision (flags, tee pads) never change the collider: the swaying flag\r\n\t\t\t// must not count as a change.\r\n\t\t\thash.Add( piece.Collision );\r\n\t\t\tif ( !piece.Collision ) continue;\r\n\t\t\tvar t = hole.ToLocal( piece.WorldTransform );\r\n\t\t\thash.Add( piece.Kind );\r\n\t\t\thash.Add( (int)MathF.Round( piece.Length * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( piece.Rise * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Scale.y * 1000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Scale.z * 1000f ) );\r\n\t\t\thash.Add( piece.CollisionIndices.Count );\r\n\t\t}\r\n\t\tforeach ( var piece in ModelPiecesBelow() )\r\n\t\t{\r\n\t\t\tvar t = hole.ToLocal( piece.WorldTransform );\r\n\t\t\thash.Add( piece.Model.Name );\r\n\t\t\thash.Add( (int)MathF.Round( piece.Model.Bounds.Mins.z * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.x * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( piece.Model.Bounds.Maxs.y * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Position.x * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Position.y * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Position.z * WeldGrid ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.x * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.y * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.z * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Rotation.w * 10000f ) );\r\n\t\t\thash.Add( (int)MathF.Round( t.Scale.x * 1000f ) );\r\n\t\t}\r\n\t\treturn hash.ToHashCode();\r\n\t}\r\n\r\n\tvoid Rebuild()\r\n\t{\r\n\t\tvar watch = Stopwatch.StartNew();\r\n\t\tRebuildCount++;\r\n\t\t_dirty = false;\r\n\t\t_lastHash = ComputeHash();\r\n\r\n\t\tvar hole = WorldTransform;\r\n\t\tvar positions = new List<Vector3>();\r\n\t\tvar indices = new List<int>();\r\n\t\tvar lookup = new Dictionary<(int, int, int), int>();\r\n\t\tvar pieces = 0;\r\n\t\tvar found = 0;\r\n\r\n\t\tforeach ( var piece in PiecesBelow() )\r\n\t\t{\r\n\t\t\tfound++;\r\n\t\t\t// The piece keeps its own collider only when it is not part of a hole.\r\n\t\t\tvar own = piece.GetComponent<ModelCollider>();\r\n\t\t\tif ( own.IsValid() && own.Enabled ) own.Enabled = false;\r\n\r\n\t\t\tpiece.EnsureBuilt();\r\n\t\t\tif ( !piece.Collision || piece.CollisionIndices.Count == 0 ) continue;\r\n\t\t\tpieces++;\r\n\r\n\t\t\tvar local = hole.ToLocal( piece.WorldTransform );\r\n\t\t\tvar source = piece.CollisionPositions;\r\n\t\t\tvar map = new int[source.Count];\r\n\t\t\tfor ( var i = 0; i < source.Count; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar p = local.PointToWorld( source[i] );\r\n\t\t\t\tvar key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));\r\n\t\t\t\tif ( !lookup.TryGetValue( key, out var index ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tindex = positions.Count;\r\n\t\t\t\t\tpositions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );\r\n\t\t\t\t\tlookup[key] = index;\r\n\t\t\t\t}\r\n\t\t\t\tmap[i] = index;\r\n\t\t\t}\r\n\r\n\t\t\tvar tris = piece.CollisionIndices;\r\n\t\t\tfor ( var i = 0; i + 2 < tris.Count; i += 3 )\r\n\t\t\t{\r\n\t\t\t\tint a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];\r\n\t\t\t\tif ( a == b || b == c || a == c ) continue; // collapsed by the weld\r\n\t\t\t\tindices.Add( a ); indices.Add( b ); indices.Add( c );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// Blender-made pieces: their model's mesh, welded in the same way; their own collider goes off.\r\n\t\tvar modelPieces = 0;\r\n\t\tforeach ( var piece in ModelPiecesBelow().ToList() )\r\n\t\t{\r\n\t\t\tfound++;\r\n\t\t\tvar model = piece.Model;\r\n\t\t\tvar source = model.GetVertices();\r\n\t\t\tvar tris = model.GetIndices();\r\n\t\t\tif ( source is null || tris is null || source.Length == 0 || tris.Length < 3 ) continue;\r\n\t\t\tif ( piece.Enabled ) piece.Enabled = false;\r\n\t\t\tmodelPieces++;\r\n\t\t\tpieces++;\r\n\r\n\t\t\tvar local = hole.ToLocal( piece.WorldTransform );\r\n\t\t\tvar map = new int[source.Length];\r\n\t\t\tfor ( var i = 0; i < source.Length; i++ )\r\n\t\t\t{\r\n\t\t\t\tvar p = local.PointToWorld( source[i].Position );\r\n\t\t\t\tvar key = ((int)MathF.Round( p.x * WeldGrid ), (int)MathF.Round( p.y * WeldGrid ), (int)MathF.Round( p.z * WeldGrid ));\r\n\t\t\t\tif ( !lookup.TryGetValue( key, out var index ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tindex = positions.Count;\r\n\t\t\t\t\tpositions.Add( new Vector3( key.Item1 / WeldGrid, key.Item2 / WeldGrid, key.Item3 / WeldGrid ) );\r\n\t\t\t\t\tlookup[key] = index;\r\n\t\t\t\t}\r\n\t\t\t\tmap[i] = index;\r\n\t\t\t}\r\n\t\t\tfor ( var i = 0; i + 2 < tris.Length; i += 3 )\r\n\t\t\t{\r\n\t\t\t\tint a = map[tris[i]], b = map[tris[i + 1]], c = map[tris[i + 2]];\r\n\t\t\t\tif ( a == b || b == c || a == c ) continue;\r\n\t\t\t\tindices.Add( a ); indices.Add( b ); indices.Add( c );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tPieces = pieces;\r\n\t\tVertices = positions.Count;\r\n\t\tTriangles = indices.Count / 3;\r\n\r\n\t\tif ( !_collider.IsValid() )\r\n\t\t{\r\n\t\t\t_collider = Components.GetOrCreate<ModelCollider>( FindMode.EverythingInSelf );\r\n\t\t\t_collider.Flags |= ComponentFlags.NotSaved; // rebuilt on load, never written to the scene file\r\n\t\t}\r\n\r\n\t\tif ( Triangles == 0 )\r\n\t\t{\r\n\t\t\t_collider.Enabled = false;\r\n\t\t\tLog.Info( $\"GolfHoleCollision: {GameObject.Name} has no collision: {found} pieces found, {Pieces} with collision data\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\t_collider.Model = Model.Builder.AddCollisionMesh( positions, indices ).Create();\r\n\t\t_collider.Static = true;\r\n\t\t_collider.Surface = Surface.FindByName( SurfaceName ) ?? _collider.Surface;\r\n\t\t_collider.Enabled = true;\r\n\r\n\t\tLog.Info( $\"GolfHoleCollision: {GameObject.Name} built {Triangles} triangles from {Pieces} of {found} pieces, {modelPieces} of them models ({Vertices} welded vertices) in {watch.ElapsedMilliseconds} ms\" );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/GolfRotator.cs",
            "FileName": "GolfRotator.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Spins the object it sits on round one of its own axes at a steady rate, on every machine and\r\n/// in the editor too (nothing is networked: every machine turns it the same way from the same\r\n/// start, and a prop that is a little out of step between machines does not matter). Made for\r\n/// the windmill's blades, whose axle is their local Y.\r\n///\r\n/// With a Rigidbody on the same object (motion off, so the body is keyframed in play) the turn\r\n/// goes through the physics body with a matching angular velocity, and on the host the box\r\n/// colliders on the object also push golf balls by hand: a ball inside the sweep is kept awake,\r\n/// and a ball an arm box overlaps is given the arm's surface speed at the contact point, so\r\n/// resting balls get swatted instead of being passed through.\r\n/// </summary>\r\n[Title( \"Golf Rotator\" ), Icon( \"sync\" ), Category( \"Golf\" )]\r\npublic sealed class GolfRotator : Component, Component.ExecuteInEditor\r\n{\r\n\t/// <summary>The local axis to turn round. The windmill blades turn round their local Y.</summary>\r\n\t[Property]\r\n\tpublic Vector3 Axis { get; set; } = new Vector3( 0f, 1f, 0f );\r\n\r\n\t/// <summary>Turning speed in degrees per second; negative turns the other way.</summary>\r\n\t[Property, Title( \"Degrees Per Second\" ), Range( -360f, 360f )]\r\n\tpublic float DegreesPerSecond { get; set; } = 30f;\r\n\r\n\tRigidbody _body;\r\n\tBoxCollider[] _arms = Array.Empty<BoxCollider>();\r\n\tfloat _reach;\r\n\tbool _loggedContact;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\t_body = GetComponent<Rigidbody>( true );\r\n\t\t_arms = GetComponents<BoxCollider>( true ).ToArray();\r\n\t\t_reach = _arms.Length == 0 ? 0f : _arms.Max( a => a.Center.Length + a.Scale.Length * 0.5f );\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// In play with a physics body the fixed step below does the turning.\r\n\t\tif ( !Scene.IsEditor && _body.IsValid() ) return;\r\n\t\tTurn( Time.Delta );\r\n\t}\r\n\r\n\tprotected override void OnFixedUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor || !_body.IsValid() ) return;\r\n\t\tvar next = Turn( Time.Delta );\r\n\t\tif ( next is null ) return;\r\n\r\n\t\t// The keyframed body is rotated with the velocity that gets it there this step, so a ball\r\n\t\t// in the blade's path is pushed rather than finding the blade somewhere else next step.\r\n\t\t_body.SmoothRotate( next.Value, Time.Delta, Time.Delta );\r\n\r\n\t\tif ( Networking.IsHost ) PushBalls();\r\n\t}\r\n\r\n\t/// <summary>Turns the object by one step and returns the new world rotation, or null when there is nothing to do.</summary>\r\n\tRotation? Turn( float seconds )\r\n\t{\r\n\t\tvar axis = Axis;\r\n\t\tif ( axis.IsNearlyZero() || DegreesPerSecond == 0f || seconds <= 0f ) return null;\r\n\t\tLocalRotation *= Rotation.FromAxis( axis.Normal, DegreesPerSecond * seconds );\r\n\t\treturn WorldRotation;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Host only. Every ball within the sweep is kept awake (a sleeping ball ignores a keyframed\r\n\t/// body sliding into it), and a ball that an arm box overlaps gets at least the arm's surface\r\n\t/// velocity along the contact normal and is lifted out of the box.\r\n\t/// </summary>\r\n\tvoid PushBalls()\r\n\t{\r\n\t\tif ( _arms.Length == 0 || DegreesPerSecond == 0f ) return;\r\n\r\n\t\tvar hub = WorldPosition;\r\n\t\tvar omega = WorldRotation * Axis.Normal * DegreesPerSecond.DegreeToRadian();\r\n\t\tvar transform = WorldTransform;\r\n\r\n\t\tforeach ( var ball in Scene.GetAllComponents<IGolfCourseBall>() )\r\n\t\t{\r\n\t\t\tif ( !ball.GameObject.IsValid() || ball.IsHoled || !ball.Rigidbody.IsValid() ) continue;\r\n\r\n\t\t\tvar pos = ball.WorldPosition;\r\n\t\t\tif ( pos.Distance( hub ) > _reach + ball.Radius + 4f ) continue;\r\n\r\n\t\t\tball.Rigidbody.Sleeping = false;\r\n\r\n\t\t\tforeach ( var arm in _arms )\r\n\t\t\t{\r\n\t\t\t\tif ( !arm.IsValid() || !arm.Enabled ) continue;\r\n\r\n\t\t\t\t// Closest point of the arm box to the ball's centre, in the box's own frame.\r\n\t\t\t\tvar local = transform.PointToLocal( pos ) - arm.Center;\r\n\t\t\t\tvar half = arm.Scale * 0.5f;\r\n\t\t\t\tvar clamped = new Vector3( Math.Clamp( local.x, -half.x, half.x ), Math.Clamp( local.y, -half.y, half.y ), Math.Clamp( local.z, -half.z, half.z ) );\r\n\t\t\t\tvar closest = transform.PointToWorld( clamped + arm.Center );\r\n\t\t\t\tvar gap = pos - closest;\r\n\t\t\t\tvar dist = gap.Length;\r\n\t\t\t\tif ( dist > ball.Radius ) continue;\r\n\r\n\t\t\t\tvar surface = Vector3.Cross( omega, closest - hub );\r\n\t\t\t\tvar normal = dist > 0.05f ? gap.Normal : surface.Normal;\r\n\t\t\t\tif ( normal.IsNearlyZero() ) continue;\r\n\r\n\t\t\t\t// Match the arm's speed into the ball; a ball already moving away is left alone.\r\n\t\t\t\tvar velocity = ball.Rigidbody.Velocity;\r\n\t\t\t\tvar relative = (velocity - surface).Dot( normal );\r\n\t\t\t\tif ( relative < 0f ) ball.Rigidbody.Velocity = velocity - normal * relative;\r\n\r\n\t\t\t\t// Lift it out of the box so the next step starts from a clean contact.\r\n\t\t\t\tif ( dist < ball.Radius - 0.1f ) ball.WorldPosition = pos + normal * (ball.Radius - dist);\r\n\r\n\t\t\t\tif ( !_loggedContact )\r\n\t\t\t\t{\r\n\t\t\t\t\t_loggedContact = true;\r\n\t\t\t\t\tLog.Info( $\"GolfRotator: {GameObject.Name} first touched {ball.OwnerName}'s ball at {closest}, arm surface speed {surface.Length:0}, ball {velocity.Length:0} -> {ball.Rigidbody.Velocity.Length:0} u/s\" );\r\n\t\t\t\t}\r\n\r\n\t\t\t\tbreak;\r\n\t\t\t}\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/GolfCourseInfo.cs",
            "FileName": "GolfCourseInfo.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// What a course scene says about itself, on one object at the scene root. The game reads it:\r\n/// the name, the ambience loop played while the scene is loaded, how far the sun's shadows reach\r\n/// (0 leaves the engine's own distance), and which wall set the Kit Wall Builder picks.\r\n/// </summary>\r\n[Title( \"Golf Course Info\" ), Category( \"Golf\" ), Icon( \"flag\" )]\r\npublic sealed class GolfCourseInfo : Component\r\n{\r\n\t/// <summary>The models the Kit Wall Builder tool places: the Blender kit (wall_100 and post_10) or the Minigolf hedge set.</summary>\r\n\tpublic enum KitWallStyle\r\n\t{\r\n\t\tKit,\r\n\t\tMinigolf,\r\n\t}\r\n\r\n\t/// <summary>The course's name, as shown to the players.</summary>\r\n\t[Property, Title( \"Course Name\" )]\r\n\tpublic string CourseName { get; set; } = \"\";\r\n\r\n\t/// <summary>A 2D loop every machine plays while this scene is loaded. None for a silent course.</summary>\r\n\t[Property]\r\n\tpublic SoundEvent Ambience { get; set; }\r\n\r\n\t/// <summary>\r\n\t/// How far from the camera the sun's shadow cascades reach, in units, while this course is\r\n\t/// loaded. A small course gets crisp shadows from a short distance; 0 keeps the engine default.\r\n\t/// </summary>\r\n\t[Property, Title( \"Shadow Distance\" ), Range( 0f, 20000f )]\r\n\tpublic float ShadowDistance { get; set; } = 0f;\r\n\r\n\t/// <summary>Which wall models the Kit Wall Builder places on this course.</summary>\r\n\t[Property, Title( \"Wall Style\" )]\r\n\tpublic KitWallStyle WallStyle { get; set; } = KitWallStyle.Kit;\r\n\r\n\t/// <summary>The course info in a scene, or null when it has none.</summary>\r\n\tpublic static GolfCourseInfo Find( Scene scene )\r\n\t{\r\n\t\treturn scene?.GetAllComponents<GolfCourseInfo>().FirstOrDefault();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/GolfCupShape.cs",
            "FileName": "GolfCupShape.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\nusing System;\n\n/// <summary>\n/// The shape of a cup piece: Straight, Curved or Trench. Changing it in the Inspector sets both the Model\n/// Renderer's and the Model Collider's model to the matching kit model, so one dropdown swaps the\n/// look and the collision, and the hole's merged collider picks the new opening up. On enable it\n/// follows whatever model the piece already has, so a model set by hand is left alone.\n///\n/// It also owns the cup's scoring trigger (the Box Collider on the same object): the trigger's\n/// centre and size for the chosen shape are re-applied every frame, in the editor and in play, so\n/// a change by hand (the Inspector's Set To GameObject Bounds, say) snaps straight back, and the\n/// collider is hidden from the Inspector.\n/// </summary>\n[Title( \"Golf Cup Shape\" ), Icon( \"sports_golf\" )]\npublic sealed class GolfCupShape : Component, Component.ExecuteInEditor\n{\n\tpublic enum CupShapeKind\n\t{\n\t\tStraight,\n\t\tCurved,\n\t\tTrench,\n\t}\n\n\tpublic const string StraightModel = \"models/kit/cup_straight.vmdl\";\n\tpublic const string CurvedModel = \"models/kit/cup_curved.vmdl\";\n\tpublic const string TrenchModel = \"models/kit/trench/trench_hole.vmdl\";\n\n\t/// <summary>Straight drops the ball straight into the cup; Curved has a bowl that funnels it in; Trench is the clay trench tile with a funnel.</summary>\n\t[Property, Title( \"Cup Shape\" )]\n\tpublic CupShapeKind Shape { get; set; } = CupShapeKind.Straight;\n\n\tCupShapeKind? _applied;\n\n\tprotected override void OnEnabled()\n\t{\n\t\t// Start from the model the piece carries, so a model chosen by hand isn't overwritten.\n\t\tvar current = Detect();\n\t\tif ( current.HasValue ) Shape = current.Value;\n\t\t_applied = Shape;\n\t}\n\n\tprotected override void OnUpdate()\n\t{\n\t\tif ( _applied != Shape ) Apply();\n\t\tOwnTrigger();\n\t}\n\n\t/// <summary>The scoring trigger's centre and size for a shape: a small box at the bottom of the cup.</summary>\n\tpublic static (Vector3 Center, Vector3 Size) TriggerFor( CupShapeKind shape ) => shape switch\n\t{\n\t\tCupShapeKind.Straight => (new Vector3( 0f, 0f, -40f ), new Vector3( 8f, 8f, 14f )),\n\t\t_ => (new Vector3( 0f, 0f, -52f ), new Vector3( 8f, 8f, 14f )),   // the curved bowl and the trench funnel are 60 deep\n\t};\n\n\t/// <summary>Puts the trigger box where this shape wants it, keeps it a trigger, and keeps it out of the Inspector.</summary>\n\tvoid OwnTrigger()\n\t{\n\t\tvar box = GetComponent<BoxCollider>( true );\n\t\tif ( box is null ) return;\n\n\t\tvar (center, size) = TriggerFor( Shape );\n\t\tif ( box.Center != center ) box.Center = center;\n\t\tif ( box.Scale != size ) box.Scale = size;\n\t\tif ( !box.IsTrigger ) box.IsTrigger = true;\n\n\t\tconst ComponentFlags owned = ComponentFlags.Hidden | ComponentFlags.NotEditable;\n\t\tif ( (box.Flags & owned) != owned ) box.Flags |= owned;\n\t}\n\n\tCupShapeKind? Detect()\n\t{\n\t\tvar name = GetComponent<ModelRenderer>( true )?.Model?.Name;\n\t\tif ( string.IsNullOrEmpty( name ) ) return null;\n\t\tif ( name.Contains( \"trench\", StringComparison.OrdinalIgnoreCase ) ) return CupShapeKind.Trench;\n\t\treturn name.Contains( \"curved\", StringComparison.OrdinalIgnoreCase ) ? CupShapeKind.Curved : CupShapeKind.Straight;\n\t}\n\n\tvoid Apply()\n\t{\n\t\tvar model = Model.Load( Shape switch { CupShapeKind.Curved => CurvedModel, CupShapeKind.Trench => TrenchModel, _ => StraightModel } );\n\t\tif ( model is null ) return;\n\n\t\tvar renderer = GetComponent<ModelRenderer>( true );\n\t\tif ( renderer is not null )\n\t\t{\n\t\t\trenderer.Model = model;\n\t\t\t// The trench tile wears its own clay; the kit cups' grass override would hide it.\n\t\t\tif ( Shape == CupShapeKind.Trench ) renderer.MaterialOverride = null;\n\t\t}\n\t\tvar collider = GetComponent<ModelCollider>( true );\n\t\tif ( collider is not null ) collider.Model = model;\n\n\t\t_applied = Shape;\n\t\tGameObject.GetComponentInParent<GolfHoleCollision>( true )?.MarkDirty();\n\t}\n}\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": ".obj/__compiler_extra.cs",
            "FileName": "__compiler_extra.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "global using static Sandbox.Internal.GlobalGameNamespace;\r\nglobal using Microsoft.AspNetCore.Components;\r\nglobal using Microsoft.AspNetCore.Components.Rendering;\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonTitle\", \"Golf With Your Guns Kit\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"AddonIdent\", \"golfkit\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"OrgIdent\", \"mrtn\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"Ident\", \"mrtn.golfkit\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineVersion\", \"30\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"EngineMinorVersion\", \"1\" )]\r\n\r\n[assembly: System.Runtime.Versioning.TargetFramework( \".NETCoreApp,Version=v9.0\", FrameworkDisplayName = \".NET 9.0\" )]\r\n[assembly: global::System.Reflection.AssemblyMetadata( \"CompileTime\", \"2026-10-17T20:21:46.3689581Z\" )]\r\n[assembly: global::System.Reflection.AssemblyVersion(\"0.0.110.0\")]\r\n[assembly: global::System.Reflection.AssemblyFileVersion(\"0.0.110.0\")]"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/GolfAudioZone.cs",
            "FileName": "GolfAudioZone.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing Sandbox.Audio;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\n/// <summary>\r\n/// An underground audio zone: a box (the Box Collider on the same object, Is Trigger on, used only\r\n/// for its size) that muffles the world while the local player's camera is inside it. The music,\r\n/// ambience, golf sound and shelling mixers (<see cref=\"GolfMixers\"/>) each get the engine's\r\n/// low-pass processor and a volume drop, faded in over Fade Time on entering and out again on\r\n/// leaving. The guns play on the default mixer and are untouched. Where zones overlap, the\r\n/// strongest wins per mixer: the lowest cutoff and the biggest drop among the zones the camera is\r\n/// in. The camera is tested against the box directly every frame, so it works for every camera,\r\n/// the player view, the fly-over, the free camera and the menu orbit; nothing is networked.\r\n///\r\n/// The engine's low-pass is a one-pole filter whose Cutoff is the per-sample smoothing factor,\r\n/// not a frequency, so Cutoff Hz is converted: alpha = 1 - exp( -2 pi f / sample rate ). A factor\r\n/// of 1 passes the sound through unchanged, which is what the fade starts from and returns to.\r\n/// Drawn in the editor as a translucent blue box.\r\n/// </summary>\r\n[Title( \"Golf Audio Zone\" ), Category( \"Golf\" ), Icon( \"hearing\" )]\r\npublic sealed class GolfAudioZone : Component\r\n{\r\n\t/// <summary>Low-pass cutoff inside the zone, Hz. Lower is more muffled.</summary>\r\n\t[Property, Group( \"Filter\" ), Range( 50f, 8000f ), Title( \"Cutoff Hz\" )]\r\n\tpublic float CutoffHz { get; set; } = 600f;\r\n\r\n\t/// <summary>Seconds the filter and the drop take to fade in or out.</summary>\r\n\t[Property, Group( \"Filter\" ), Range( 0f, 5f ), Title( \"Fade Time\" )]\r\n\tpublic float FadeTime { get; set; } = 0.6f;\r\n\r\n\t/// <summary>How much of the volume is taken away inside, 0 to 1; 0.2 leaves 80 percent.</summary>\r\n\t[Property, Group( \"Filter\" ), Range( 0f, 1f ), Title( \"Volume Drop\" )]\r\n\tpublic float VolumeDrop { get; set; } = 0.2f;\r\n\r\n\t[Property, Group( \"Mixers\" ), Title( \"Music\" )]\r\n\tpublic bool AffectMusic { get; set; } = true;\r\n\r\n\t[Property, Group( \"Mixers\" ), Title( \"Ambience\" )]\r\n\tpublic bool AffectAmbience { get; set; } = true;\r\n\r\n\t[Property, Group( \"Mixers\" ), Title( \"Golf Sounds\" )]\r\n\tpublic bool AffectSounds { get; set; } = true;\r\n\r\n\t[Property, Group( \"Mixers\" ), Title( \"Shelling\" )]\r\n\tpublic bool AffectShelling { get; set; } = true;\r\n\r\n\t// Every enabled zone; the first one drives the mixers for all of them.\r\n\tstatic readonly List<GolfAudioZone> Zones = new();\r\n\r\n\t// Per mixer: the processor, the current smoothing factor (1 = untouched) and volume (1 = full).\r\n\tsealed class Channel\r\n\t{\r\n\t\tpublic string Name;\r\n\t\tpublic LowPassProcessor Filter;\r\n\t\tpublic Mixer Mixer;\r\n\t\tpublic float Alpha = 1f;\r\n\t\tpublic float Volume = 1f;\r\n\t\t// Zones muffle this mixer; the default \"Game\" mixer (guns, footsteps) is only muffled by an outside request.\r\n\t\tpublic bool ZoneControlled = true;\r\n\t}\r\n\r\n\tstatic readonly Channel[] Channels =\r\n\t{\r\n\t\tnew() { Name = GolfMixers.MusicName },\r\n\t\tnew() { Name = GolfMixers.AmbienceName },\r\n\t\tnew() { Name = GolfMixers.SoundsName },\r\n\t\tnew() { Name = GolfMixers.ShellingName },\r\n\t\tnew() { Name = \"Game\", ZoneControlled = false },\r\n\t};\r\n\r\n\t// An outside request to muffle everything (the distraction hit): cutoff factor, volume and how\r\n\t// strong it is right now, 0 to 1. Made fresh every frame by the requester; it lapses by itself.\r\n\tstatic float _muffleAlpha = 1f, _muffleVolume = 1f, _muffleStrength;\r\n\tstatic float _muffleFrame = -1f;\r\n\r\n\t/// <summary>\r\n\t/// Muffle every mixer, the guns included, this frame: a low-pass at this cutoff and a volume\r\n\t/// drop, scaled by strength (0 none, 1 full). Call it every frame it should apply; the caller\r\n\t/// shapes the fade in and out through the strength. Overlapping audio zones still take the strongest.\r\n\t/// </summary>\r\n\tpublic static void SetMuffle( float cutoffHz, float volumeDrop, float strength )\r\n\t{\r\n\t\t_muffleAlpha = AlphaFor( cutoffHz );\r\n\t\t_muffleVolume = 1f - Math.Clamp( volumeDrop, 0f, 1f );\r\n\t\t_muffleStrength = Math.Clamp( strength, 0f, 1f );\r\n\t\t_muffleFrame = Time.Now;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Runs the mixer update when no zone is there to do it, so an outside muffle request works in\r\n\t/// scenes without audio zones. A zone that exists drives the mixers itself.\r\n\t/// </summary>\r\n\tpublic static void TickWithoutZones()\r\n\t{\r\n\t\tif ( Zones.Count > 0 ) return;\r\n\t\tTick();\r\n\t}\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tif ( !Zones.Contains( this ) ) Zones.Add( this );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tZones.Remove( this );\r\n\t\tif ( Zones.Count == 0 ) ResetMixers();\r\n\t}\r\n\r\n\t/// <summary>True when a world point is inside this zone's box.</summary>\r\n\tpublic bool Contains( Vector3 worldPosition )\r\n\t{\r\n\t\tvar local = WorldTransform.PointToLocal( worldPosition );\r\n\t\treturn Box().Contains( local );\r\n\t}\r\n\r\n\tbool Affects( int channel ) => channel switch { 0 => AffectMusic, 1 => AffectAmbience, 2 => AffectSounds, _ => AffectShelling };\r\n\r\n\tBBox Box()\r\n\t{\r\n\t\tvar box = GetComponent<BoxCollider>( true );\r\n\t\treturn box is not null\r\n\t\t\t? BBox.FromPositionAndSize( box.Center, box.Scale )\r\n\t\t\t: BBox.FromPositionAndSize( new Vector3( 0f, 0f, 200f ), new Vector3( 400f, 400f, 400f ) );\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor ) return;\r\n\t\t// One zone does the work for all of them, so overlapping zones blend into one answer.\r\n\t\tif ( Zones.Count == 0 || Zones[0] != this ) return;\r\n\t\tTick();\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// One update of every mixer: the strongest zone the camera is in per mixer, fading at that\r\n\t/// zone's Fade Time, and the outside muffle request on top, which follows its own strength at once.\r\n\t/// </summary>\r\n\tstatic void Tick()\r\n\t{\r\n\t\tvar scene = Game.ActiveScene;\r\n\t\tvar camera = scene?.Camera;\r\n\t\tvar position = camera.IsValid() ? camera.WorldPosition : Vector3.Zero;\r\n\t\tvar muffling = _muffleFrame >= Time.Now - 0.1f && _muffleStrength > 0f;\r\n\r\n\t\tfor ( var i = 0; i < Channels.Length; i++ )\r\n\t\t{\r\n\t\t\tvar channel = Channels[i];\r\n\r\n\t\t\t// The strongest zone the camera is in, per mixer.\r\n\t\t\tvar targetAlpha = 1f;\r\n\t\t\tvar targetVolume = 1f;\r\n\t\t\tvar fade = 0.6f;\r\n\t\t\tif ( channel.ZoneControlled )\r\n\t\t\t{\r\n\t\t\t\tforeach ( var zone in Zones )\r\n\t\t\t\t{\r\n\t\t\t\t\tif ( !zone.IsValid() || !zone.Affects( i ) ) continue;\r\n\t\t\t\t\tif ( !camera.IsValid() || !zone.Contains( position ) ) continue;\r\n\t\t\t\t\tvar alpha = AlphaFor( zone.CutoffHz );\r\n\t\t\t\t\tif ( alpha < targetAlpha ) { targetAlpha = alpha; fade = zone.FadeTime; }\r\n\t\t\t\t\ttargetVolume = Math.Min( targetVolume, 1f - Math.Clamp( zone.VolumeDrop, 0f, 1f ) );\r\n\t\t\t\t}\r\n\t\t\t}\r\n\r\n\t\t\t// The outside request wins where it is stronger, and is followed at once: its caller shapes the fade.\r\n\t\t\tvar instant = false;\r\n\t\t\tif ( muffling )\r\n\t\t\t{\r\n\t\t\t\tvar alpha = MathX.Lerp( 1f, _muffleAlpha, _muffleStrength );\r\n\t\t\t\tvar volume = MathX.Lerp( 1f, _muffleVolume, _muffleStrength );\r\n\t\t\t\tif ( alpha < targetAlpha ) { targetAlpha = alpha; instant = true; }\r\n\t\t\t\tif ( volume < targetVolume ) { targetVolume = volume; instant = true; }\r\n\t\t\t}\r\n\t\t\telse if ( !channel.ZoneControlled || Zones.Count == 0 )\r\n\t\t\t{\r\n\t\t\t\tinstant = true;   // nothing asks for anything: straight back to clean\r\n\t\t\t}\r\n\r\n\t\t\tvar step = instant || fade <= 0f ? 1f : Math.Clamp( Time.Delta / fade, 0f, 1f );\r\n\t\t\t// Linear in the smoothing factor sounds right: it sweeps the muffling smoothly.\r\n\t\t\tchannel.Alpha = MathX.Lerp( channel.Alpha, targetAlpha, step );\r\n\t\t\tchannel.Volume = MathX.Lerp( channel.Volume, targetVolume, step );\r\n\t\t\tif ( Math.Abs( channel.Alpha - targetAlpha ) < 0.002f ) channel.Alpha = targetAlpha;\r\n\t\t\tif ( Math.Abs( channel.Volume - targetVolume ) < 0.002f ) channel.Volume = targetVolume;\r\n\t\t\tApply( channel );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>The mixer's sample rate; the engine's own figure is not exposed to game code.</summary>\r\n\tconst float SampleRate = 44100f;\r\n\r\n\t/// <summary>The one-pole smoothing factor that gives this cutoff at the mix sample rate.</summary>\r\n\tstatic float AlphaFor( float hz )\r\n\t{\r\n\t\treturn Math.Clamp( 1f - MathF.Exp( -2f * MathF.PI * Math.Max( 10f, hz ) / SampleRate ), 0.001f, 1f );\r\n\t}\r\n\r\n\tstatic void Apply( Channel channel )\r\n\t{\r\n\t\tif ( channel.Mixer is null )\r\n\t\t{\r\n\t\t\tchannel.Mixer = GolfMixers.Get( channel.Name );\r\n\t\t\tif ( channel.Mixer is null ) return;\r\n\t\t}\r\n\r\n\t\tvar untouched = channel.Alpha >= 0.999f;\r\n\t\tif ( channel.Filter is null )\r\n\t\t{\r\n\t\t\tif ( untouched ) { channel.Mixer.Volume = channel.Volume; return; }\r\n\t\t\tchannel.Filter = channel.Mixer.GetProcessor<LowPassProcessor>();\r\n\t\t\tif ( channel.Filter is null )\r\n\t\t\t{\r\n\t\t\t\tchannel.Filter = new LowPassProcessor { Cutoff = 1f };\r\n\t\t\t\tchannel.Mixer.AddProcessor( channel.Filter );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tchannel.Filter.Cutoff = channel.Alpha;\r\n\t\tchannel.Filter.Enabled = !untouched;\r\n\t\tchannel.Mixer.Volume = channel.Volume;\r\n\t}\r\n\r\n\t/// <summary>No zones left (scene change, all disabled): every mixer back to clean and full.</summary>\r\n\tstatic void ResetMixers()\r\n\t{\r\n\t\tforeach ( var channel in Channels )\r\n\t\t{\r\n\t\t\tchannel.Alpha = 1f;\r\n\t\t\tchannel.Volume = 1f;\r\n\t\t\tif ( channel.Mixer is null ) continue;\r\n\t\t\tif ( channel.Filter is not null )\r\n\t\t\t{\r\n\t\t\t\tchannel.Filter.Cutoff = 1f;\r\n\t\t\t\tchannel.Filter.Enabled = false;\r\n\t\t\t}\r\n\t\t\tchannel.Mixer.Volume = 1f;\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tvar bounds = Box();\r\n\t\tvar alpha = Gizmo.IsSelected ? 0.35f : (Gizmo.IsHovered ? 0.25f : 0.12f);\r\n\t\tvar blue = new Color( 0.25f, 0.55f, 1f );\r\n\t\tGizmo.Draw.Color = blue.WithAlpha( alpha );\r\n\t\tGizmo.Draw.SolidBox( bounds );\r\n\t\tGizmo.Draw.LineThickness = Gizmo.IsSelected ? 2f : 1f;\r\n\t\tGizmo.Draw.Color = blue.WithAlpha( Gizmo.IsSelected ? 0.9f : 0.5f );\r\n\t\tGizmo.Draw.LineBBox( bounds );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/GolfMenuCamera.cs",
            "FileName": "GolfMenuCamera.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Where the camera sits behind the main menu, the course settings and the lobby. Place one in a\r\n/// scene (the menu_camera prefab) and the fly-over's menu orbit is replaced by this view: the\r\n/// object's position and rotation with a gentle sway and a slight push-in. A child object named\r\n/// End makes it drift back and forth between the two points as well. Without one in the scene\r\n/// the old orbit round the start hole is used. Editor gizmo: the camera's frustum, and the path\r\n/// to End when there is one.\r\n/// </summary>\r\n[Title( \"Golf Menu Camera\" ), Icon( \"videocam\" ), Category( \"Golf\" )]\r\npublic sealed class GolfMenuCamera : Component\r\n{\r\n\t/// <summary>The menu camera in the loaded scene, or null.</summary>\r\n\tpublic static GolfMenuCamera Current { get; private set; }\r\n\r\n\t/// <summary>How far the sway moves the camera sideways and up, in units. 0 holds it still.</summary>\r\n\t[Property, Title( \"Drift Amount\" ), Range( 0f, 200f )]\r\n\tpublic float DriftAmount { get; set; } = 24f;\r\n\r\n\t/// <summary>Seconds for one full sway back and forth.</summary>\r\n\t[Property, Title( \"Drift Period\" ), Range( 2f, 60f )]\r\n\tpublic float DriftPeriod { get; set; } = 14f;\r\n\r\n\t/// <summary>How far the camera eases forward and back along its view, in units.</summary>\r\n\t[Property, Title( \"Push In\" ), Range( 0f, 400f )]\r\n\tpublic float PushIn { get; set; } = 40f;\r\n\r\n\t/// <summary>Degrees of slow look-around that go with the sway. 0 keeps the rotation fixed.</summary>\r\n\t[Property, Title( \"Sway Degrees\" ), Range( 0f, 10f )]\r\n\tpublic float SwayDegrees { get; set; } = 1.5f;\r\n\r\n\t/// <summary>Seconds for one trip from here to End and back, when a child named End exists.</summary>\r\n\t[Property, Title( \"Loop Seconds\" ), Range( 4f, 120f )]\r\n\tpublic float LoopSeconds { get; set; } = 30f;\r\n\r\n\t/// <summary>Vertical field of view of the menu view.</summary>\r\n\t[Property, Title( \"Field Of View\" ), Range( 30f, 110f )]\r\n\tpublic float FieldOfView { get; set; } = 70f;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tCurrent = this;\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tif ( Current == this ) Current = null;\r\n\t}\r\n\r\n\t/// <summary>The End child, if the camera loops between two points.</summary>\r\n\tGameObject End => GameObject.Children.FirstOrDefault( c => c.IsValid() && string.Equals( c.Name, \"End\", StringComparison.OrdinalIgnoreCase ) );\r\n\r\n\t/// <summary>The camera's position and rotation at a moment in time, drift and all.</summary>\r\n\tpublic Transform ViewAt( float time )\r\n\t{\r\n\t\tvar start = WorldTransform;\r\n\t\tvar position = start.Position;\r\n\t\tvar rotation = start.Rotation;\r\n\r\n\t\t// Back and forth between here and End, easing at both ends.\r\n\t\tvar end = End;\r\n\t\tif ( end.IsValid() && LoopSeconds > 0f )\r\n\t\t{\r\n\t\t\tvar phase = (time % LoopSeconds) / LoopSeconds;          // 0..1 over one round trip\r\n\t\t\tvar t = phase < 0.5f ? phase * 2f : 2f - phase * 2f;      // 0 -> 1 -> 0\r\n\t\t\tt = t * t * (3f - 2f * t);                                // smoothstep\r\n\t\t\tposition = Vector3.Lerp( start.Position, end.WorldPosition, t );\r\n\t\t\trotation = Rotation.Slerp( start.Rotation, end.WorldRotation, t );\r\n\t\t}\r\n\r\n\t\t// The sway: a slow figure of eight sideways and up, with a push-in on a longer beat.\r\n\t\tvar period = MathF.Max( 2f, DriftPeriod );\r\n\t\tvar w = MathF.Tau * time / period;\r\n\t\tvar sideways = MathF.Sin( w ) * DriftAmount;\r\n\t\tvar upward = MathF.Sin( w * 0.5f + 1.3f ) * DriftAmount * 0.5f;\r\n\t\tvar forward = (0.5f + 0.5f * MathF.Sin( w * 0.25f )) * PushIn;\r\n\r\n\t\tvar swayed = rotation * Rotation.From( MathF.Sin( w * 0.5f ) * SwayDegrees * 0.5f, MathF.Sin( w ) * SwayDegrees, 0f );\r\n\t\tposition += rotation.Right * sideways + rotation.Up * upward + rotation.Forward * forward;\r\n\r\n\t\treturn new Transform( position, swayed );\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tvar selected = Gizmo.IsSelected || Gizmo.IsHovered;\r\n\t\tvar colour = new Color( 0.37f, 0.83f, 0.42f );\r\n\t\tGizmo.Draw.LineThickness = selected ? 2f : 1f;\r\n\t\tGizmo.Draw.Color = colour.WithAlpha( selected ? 0.95f : 0.55f );\r\n\r\n\t\t// The frustum in local space: the near and far rectangles of the field of view, joined up.\r\n\t\tvar aspect = 16f / 9f;\r\n\t\tvar near = 20f;\r\n\t\tvar far = selected ? 600f : 300f;\r\n\t\tvar tan = MathF.Tan( MathX.DegreeToRadian( FieldOfView ) * 0.5f );\r\n\t\tVector3[] Corners( float d )\r\n\t\t{\r\n\t\t\tvar h = tan * d;\r\n\t\t\tvar w = h * aspect;\r\n\t\t\treturn new[]\r\n\t\t\t{\r\n\t\t\t\tnew Vector3( d, -w, -h ), new Vector3( d, w, -h ),\r\n\t\t\t\tnew Vector3( d, w, h ), new Vector3( d, -w, h ),\r\n\t\t\t};\r\n\t\t}\r\n\t\tvar n = Corners( near );\r\n\t\tvar f = Corners( far );\r\n\t\tfor ( var i = 0; i < 4; i++ )\r\n\t\t{\r\n\t\t\tGizmo.Draw.Line( n[i], n[(i + 1) % 4] );\r\n\t\t\tGizmo.Draw.Line( f[i], f[(i + 1) % 4] );\r\n\t\t\tGizmo.Draw.Line( Vector3.Zero, f[i] );\r\n\t\t}\r\n\r\n\t\t// A small body so it can be found and clicked.\r\n\t\tGizmo.Draw.LineBBox( new BBox( new Vector3( -14f, -8f, -8f ), new Vector3( 4f, 8f, 8f ) ) );\r\n\r\n\t\t// The loop path to End, in world space.\r\n\t\tvar end = End;\r\n\t\tif ( end.IsValid() )\r\n\t\t{\r\n\t\t\tvar local = WorldTransform.PointToLocal( end.WorldPosition );\r\n\t\t\tGizmo.Draw.Color = colour.WithAlpha( 0.8f );\r\n\t\t\tGizmo.Draw.Arrow( Vector3.Zero, local, 12f, 4f );\r\n\t\t}\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/MudWaterRipple.cs",
            "FileName": "MudWaterRipple.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Ripples on a mud water plane. Every Interval (a random time between the min and max) a\r\n/// shockwave starts at a random point on the plane: a flat overlay quad carrying a ring texture\r\n/// (three concentric rings in its alpha and normal map, from Tools/make_mud_water.py) is placed on\r\n/// the surface, scaled up at Ring Speed until it reaches Max Radius, and fades to nothing on the\r\n/// way, about two seconds at the defaults. The overlay's fade is the renderer's Tint alpha; the\r\n/// complex shader has no attribute to drive a ripple inside the water material itself, so the\r\n/// overlay is the cheap route in this build. Nothing is networked, every machine ripples on its\r\n/// own; a small pool of overlays, no allocation per ripple. Put it on the object that renders the\r\n/// water plane: the plane's extent is that renderer's model bounds.\r\n///\r\n/// Between ripples the water also sways in place: the plane's material is copied at start and the\r\n/// copy's texture offset is moved round a tiny circle every frame (the complex shader has no\r\n/// wobble or animated normal option, only a straight scroll, so this is done from code). Nothing\r\n/// flows, the texture only drifts back and forth by a fraction of a texel.\r\n/// </summary>\r\n[Title( \"Mud Water Ripple\" ), Category( \"Golf\" ), Icon( \"water\" )]\r\npublic sealed class MudWaterRipple : Component\r\n{\r\n\t/// <summary>Shortest wait between ripples, seconds.</summary>\r\n\t[Property, Range( 0.5f, 60f ), Title( \"Interval Min\" )]\r\n\tpublic float IntervalMin { get; set; } = 5f;\r\n\r\n\t/// <summary>Longest wait between ripples, seconds.</summary>\r\n\t[Property, Range( 0.5f, 60f ), Title( \"Interval Max\" )]\r\n\tpublic float IntervalMax { get; set; } = 15f;\r\n\r\n\t/// <summary>How fast the rings spread, units per second.</summary>\r\n\t[Property, Range( 10f, 1000f ), Title( \"Ring Speed\" )]\r\n\tpublic float RingSpeed { get; set; } = 100f;\r\n\r\n\t/// <summary>How visible the rings are when they start, 0 to 1.</summary>\r\n\t[Property, Range( 0f, 1f ), Title( \"Ring Strength\" )]\r\n\tpublic float RingStrength { get; set; } = 0.8f;\r\n\r\n\t/// <summary>Radius the rings reach before they are gone, units.</summary>\r\n\t[Property, Range( 20f, 2000f ), Title( \"Max Radius\" )]\r\n\tpublic float MaxRadius { get; set; } = 200f;\r\n\r\n\t/// <summary>Ripples in flight at once; the pool size.</summary>\r\n\t[Property, Range( 1, 8 )]\r\n\tpublic int MaxRipples { get; set; } = 3;\r\n\r\n\t/// <summary>Sway the water texture in place between ripples.</summary>\r\n\t[Property, Group( \"Wobble\" )]\r\n\tpublic bool Wobble { get; set; } = true;\r\n\r\n\t/// <summary>How far the texture sways, in UV units; 0.002 is a fraction of a texel.</summary>\r\n\t[Property, Group( \"Wobble\" ), Range( 0f, 0.02f ), Title( \"Wobble Radius\" )]\r\n\tpublic float WobbleRadius { get; set; } = 0.002f;\r\n\r\n\t/// <summary>Seconds for one full sway round the circle.</summary>\r\n\t[Property, Group( \"Wobble\" ), Range( 1f, 30f ), Title( \"Wobble Period\" )]\r\n\tpublic float WobblePeriod { get; set; } = 6f;\r\n\r\n\tconst string QuadModel = \"models/mud_water/ripple_quad.vmdl\";\r\n\tconst string RingMaterial = \"textures/mud_water/ripple_ring.vmat\";\r\n\tconst float Lift = 0.75f;   // the overlay floats this far above the water so it never z-fights\r\n\r\n\tModelRenderer[] _overlays = Array.Empty<ModelRenderer>();\r\n\tfloat[] _born = Array.Empty<float>();\r\n\tint _next;\r\n\tfloat _nextAt;\r\n\tModelRenderer _plane;\r\n\tMaterial _planeMaterial;   // what the plane had before the wobble copy, put back on disable\r\n\tMaterial _wobble;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tvar model = Model.Load( QuadModel );\r\n\t\tvar material = Material.Load( RingMaterial );\r\n\t\tif ( model is null || material is null )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"MudWaterRipple: missing {QuadModel} or {RingMaterial}\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar count = Math.Clamp( MaxRipples, 1, 8 );\r\n\t\t_overlays = new ModelRenderer[count];\r\n\t\t_born = new float[count];\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tvar go = new GameObject( GameObject, false, $\"ripple {i}\" );\r\n\t\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;\r\n\t\t\tvar renderer = go.AddComponent<ModelRenderer>();\r\n\t\t\trenderer.Model = model;\r\n\t\t\trenderer.MaterialOverride = material;\r\n\t\t\t_overlays[i] = renderer;\r\n\t\t}\r\n\r\n\t\t_nextAt = Time.Now + Wait();\r\n\r\n\t\t// The wobble works on a private copy of the plane's material, so the asset itself is untouched.\r\n\t\t_plane = GetComponent<ModelRenderer>();\r\n\t\tvar source = _plane?.MaterialOverride ?? _plane?.Model?.Materials.FirstOrDefault();\r\n\t\tif ( Wobble && source is not null )\r\n\t\t{\r\n\t\t\t_planeMaterial = _plane.MaterialOverride;\r\n\t\t\t_wobble = source.CreateCopy( \"mud water wobble\" );\r\n\t\t\t_plane.MaterialOverride = _wobble;\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tforeach ( var overlay in _overlays )\r\n\t\t\tif ( overlay.IsValid() ) overlay.GameObject.Enabled = false;\r\n\r\n\t\tif ( _wobble is not null && _plane.IsValid() && _plane.MaterialOverride == _wobble )\r\n\t\t\t_plane.MaterialOverride = _planeMaterial;\r\n\t\t_wobble = null;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor || _overlays.Length == 0 ) return;\r\n\r\n\t\tvar now = Time.Now;\r\n\r\n\t\tif ( _wobble is not null )\r\n\t\t{\r\n\t\t\tvar angle = now / Math.Max( 0.1f, WobblePeriod ) * MathF.PI * 2f;\r\n\t\t\tvar radius = Wobble ? WobbleRadius : 0f;\r\n\t\t\t_wobble.Set( \"g_vTexCoordOffset\", new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * radius );\r\n\t\t}\r\n\t\tvar life = MaxRadius / Math.Max( 1f, RingSpeed );\r\n\r\n\t\tfor ( var i = 0; i < _overlays.Length; i++ )\r\n\t\t{\r\n\t\t\tvar overlay = _overlays[i];\r\n\t\t\tif ( !overlay.IsValid() || !overlay.GameObject.Enabled ) continue;\r\n\r\n\t\t\tvar age = now - _born[i];\r\n\t\t\tif ( age >= life )\r\n\t\t\t{\r\n\t\t\t\toverlay.GameObject.Enabled = false;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tvar t = age / life;\r\n\t\t\tvar radius = Math.Max( 1f, RingSpeed * age );\r\n\t\t\toverlay.GameObject.WorldScale = new Vector3( radius, radius, 1f );\r\n\t\t\toverlay.Tint = Color.White.WithAlpha( RingStrength * (1f - t) * (1f - t) );\r\n\t\t}\r\n\r\n\t\tif ( now < _nextAt ) return;\r\n\t\t_nextAt = now + Wait();\r\n\t\tSpawn();\r\n\t}\r\n\r\n\tfloat Wait()\r\n\t{\r\n\t\tvar min = Math.Min( IntervalMin, IntervalMax );\r\n\t\tvar max = Math.Max( IntervalMin, IntervalMax );\r\n\t\treturn Game.Random.Float( min, max );\r\n\t}\r\n\r\n\t/// <summary>A ripple at a random point on the plane's top surface.</summary>\r\n\tvoid Spawn()\r\n\t{\r\n\t\tvar i = _next;\r\n\t\t_next = (_next + 1) % _overlays.Length;\r\n\t\tvar overlay = _overlays[i];\r\n\t\tif ( !overlay.IsValid() ) return;\r\n\r\n\t\tvar plane = GetComponent<ModelRenderer>();\r\n\t\tvar bounds = plane?.Model?.Bounds ?? new BBox( new Vector3( -100f, -100f, 0f ), new Vector3( 100f, 100f, 0f ) );\r\n\t\tvar local = new Vector3(\r\n\t\t\tGame.Random.Float( bounds.Mins.x, bounds.Maxs.x ),\r\n\t\t\tGame.Random.Float( bounds.Mins.y, bounds.Maxs.y ),\r\n\t\t\tbounds.Maxs.z );\r\n\t\tvar position = WorldTransform.PointToWorld( local ) + Vector3.Up * Lift;\r\n\r\n\t\toverlay.GameObject.WorldPosition = position;\r\n\t\toverlay.GameObject.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );\r\n\t\toverlay.GameObject.WorldScale = new Vector3( 1f, 1f, 1f );\r\n\t\toverlay.Tint = Color.White.WithAlpha( RingStrength );\r\n\t\t_born[i] = Time.Now;\r\n\t\toverlay.GameObject.Enabled = true;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/TrenchShelling.cs",
            "FileName": "TrenchShelling.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// Distant artillery for the trench map. Every Interval (a fresh random wait between the min and\r\n/// max, read from the Inspector each time a shell goes off) the host picks a shell: usually far\r\n/// off, a random yaw and a distance in the Far range; sometimes (Near Chance) a near one in the\r\n/// Near range. The host tells every machine the yaw, the distance and the kind, and each machine\r\n/// places the shell at that yaw and distance from its own camera, so every player gets the same\r\n/// near or far shell wherever they stand: an explosion sound in 3D at that spot (the far event is\r\n/// pitched down and softened, the near one is as it comes) and a camera shake sized by the\r\n/// distance, a soft rumble of about a second for a far one, a hard kick and a rolling two second\r\n/// decay plus a low rumble layer for a near one. No damage, nothing happens to balls, no particles.\r\n/// Runs from scene load, so the menu and the lobby hear it too. Place one anywhere; its position\r\n/// does not matter, and only one instance keeps the clock.\r\n///\r\n/// The shake is smooth noise, new every frame: layered sines on position (up to the shake\r\n/// strength in units) and on pitch, yaw and roll (up to Shake Angle, scaled by the strength), with\r\n/// a fast attack and an exponential decay. It reaches whichever camera is active. The engine\r\n/// composes each camera's view once per tick after Update, through the camera modifiers, and\r\n/// the shake is one of those (after the fly-over and the free camera). But the fly-over, the\r\n/// free camera and the menu orbit write the camera's transform in PreRender, after that\r\n/// composition, and the engine then renders from the raw transform and drops the composed view.\r\n/// So at the very end of the tick (the FinishUpdate stage, after every PreRender) the\r\n/// <see cref=\"TrenchShellingSystem\"/> checks whether the active camera's transform changed after\r\n/// composition and, if it did, adds the same offset to the transform itself, undoing it again at\r\n/// the start of the next tick so nothing accumulates.\r\n/// </summary>\r\n[Title( \"Trench Shelling\" ), Category( \"Golf\" ), Icon( \"flare\" )]\r\npublic sealed class TrenchShelling : Component, ICameraModifier\r\n{\r\n\t/// <summary>The instance that keeps the clock and shakes the camera; the first enabled one.</summary>\r\n\tpublic static TrenchShelling Current { get; private set; }\r\n\r\n\t/// <summary>Shortest wait between shells, seconds.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1f, 300f ), Title( \"Interval Min\" )]\r\n\tpublic float IntervalMin { get; set; } = 20f;\r\n\r\n\t/// <summary>Longest wait between shells, seconds.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1f, 300f ), Title( \"Interval Max\" )]\r\n\tpublic float IntervalMax { get; set; } = 60f;\r\n\r\n\t/// <summary>Closest a far shell lands, units from each player's camera.</summary>\r\n\t[Property, Group( \"Distance\" ), Range( 500f, 20000f ), Title( \"Far Distance Min\" )]\r\n\tpublic float FarMin { get; set; } = 2000f;\r\n\r\n\t/// <summary>Furthest a far shell lands, units from each player's camera.</summary>\r\n\t[Property, Group( \"Distance\" ), Range( 500f, 20000f ), Title( \"Far Distance Max\" )]\r\n\tpublic float FarMax { get; set; } = 6000f;\r\n\r\n\t/// <summary>Chance a shell is a near one, 0 to 1.</summary>\r\n\t[Property, Group( \"Distance\" ), Range( 0f, 1f ), Title( \"Near Chance\" )]\r\n\tpublic float NearChance { get; set; } = 0.15f;\r\n\r\n\t/// <summary>Closest a near shell lands, units from each player's camera.</summary>\r\n\t[Property, Group( \"Distance\" ), Range( 50f, 5000f ), Title( \"Near Distance Min\" )]\r\n\tpublic float NearMin { get; set; } = 300f;\r\n\r\n\t/// <summary>Furthest a near shell lands, units from each player's camera.</summary>\r\n\t[Property, Group( \"Distance\" ), Range( 50f, 5000f ), Title( \"Near Distance Max\" )]\r\n\tpublic float NearMax { get; set; } = 800f;\r\n\r\n\t/// <summary>The far shell: pitched down and softened. Empty uses sounds/trench/shell_far.</summary>\r\n\t[Property, Group( \"Sound\" ), Title( \"Far Sound\" )]\r\n\tpublic SoundEvent FarSound { get; set; }\r\n\r\n\t/// <summary>The near shell. Empty uses sounds/trench/shell_near.</summary>\r\n\t[Property, Group( \"Sound\" ), Title( \"Near Sound\" )]\r\n\tpublic SoundEvent NearSound { get; set; }\r\n\r\n\t/// <summary>Low rumble layered under a near shell. Empty uses sounds/trench/shell_rumble; None for none.</summary>\r\n\t[Property, Group( \"Sound\" ), Title( \"Rumble Sound\" )]\r\n\tpublic SoundEvent RumbleSound { get; set; }\r\n\r\n\t/// <summary>Volume of every shell sound, 0 to 2.</summary>\r\n\t[Property, Group( \"Sound\" ), Range( 0f, 2f )]\r\n\tpublic float Volume { get; set; } = 1f;\r\n\r\n\t/// <summary>Shake of a far shell for a camera right next to it, world units.</summary>\r\n\t[Property, Group( \"Shake\" ), Range( 0f, 10f ), Title( \"Far Shake\" )]\r\n\tpublic float FarShake { get; set; } = 1.2f;\r\n\r\n\t/// <summary>How long a far shell rumbles, seconds.</summary>\r\n\t[Property, Group( \"Shake\" ), Range( 0f, 10f ), Title( \"Far Shake Time\" )]\r\n\tpublic float FarShakeTime { get; set; } = 1f;\r\n\r\n\t/// <summary>Shake of a near shell for a camera right next to it, world units.</summary>\r\n\t[Property, Group( \"Shake\" ), Range( 0f, 10f ), Title( \"Near Shake\" )]\r\n\tpublic float NearShake { get; set; } = 6f;\r\n\r\n\t/// <summary>How long a near shell shakes, seconds.</summary>\r\n\t[Property, Group( \"Shake\" ), Range( 0f, 10f ), Title( \"Near Shake Time\" )]\r\n\tpublic float NearShakeTime { get; set; } = 2f;\r\n\r\n\t/// <summary>Speed of the shake noise, wobbles per second.</summary>\r\n\t[Property, Group( \"Shake\" ), Range( 1f, 60f ), Title( \"Shake Frequency\" )]\r\n\tpublic float ShakeFrequency { get; set; } = 9f;\r\n\r\n\t/// <summary>Tilt of the view at full Near Shake strength, degrees; smaller shakes tilt less in proportion.</summary>\r\n\t[Property, Group( \"Shake\" ), Range( 0f, 10f ), Title( \"Shake Angle\" )]\r\n\tpublic float ShakeAngle { get; set; } = 2f;\r\n\r\n\t/// <summary>Distance from the camera at which a shell's shake has faded to nothing, units.</summary>\r\n\t[Property, Group( \"Shake\" ), Range( 500f, 30000f ), Title( \"Shake Range\" )]\r\n\tpublic float ShakeRange { get; set; } = 9000f;\r\n\r\n\tconst string FarSoundPath = \"sounds/trench/shell_far.sound\";\r\n\tconst string NearSoundPath = \"sounds/trench/shell_near.sound\";\r\n\tconst string RumbleSoundPath = \"sounds/trench/shell_rumble.sound\";\r\n\r\n\t// The host's clock. A TimeUntil survives a hotload and counts in scene time.\r\n\tTimeUntil _nextShell;\r\n\tbool _scheduled;\r\n\r\n\t// The shake in progress.\r\n\tfloat _amplitude;        // units of position shake at the start\r\n\tfloat _started;          // Time.Now it began\r\n\tfloat _duration;\r\n\tbool _near;\r\n\tVector3 _kickDirection;  // the near shell's first hard knock\r\n\tint _shell;              // counts shells, so the camera line is logged once each\r\n\tint _loggedShell;\r\n\r\n\t// The camera the view was composed for this tick, and its raw transform at that moment.\r\n\tCameraComponent _composedCamera;\r\n\tVector3 _composedPosition;\r\n\tRotation _composedRotation;\r\n\tbool _composedThisTick;\r\n\r\n\t// An offset written into a camera's transform at the end of a tick, taken out again at the start of the next.\r\n\tCameraComponent _rawCamera;\r\n\tVector3 _rawOffset;\r\n\tRotation _rawTilt = Rotation.Identity;\r\n\tbool _rawApplied;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tif ( !Current.IsValid() || Current.Scene != Scene ) Current = this;\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tRestoreEarly();\r\n\t\tif ( Current == this ) Current = null;\r\n\t}\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tFarSound ??= ResourceLibrary.Get<SoundEvent>( FarSoundPath );\r\n\t\tNearSound ??= ResourceLibrary.Get<SoundEvent>( NearSoundPath );\r\n\t\tRumbleSound ??= ResourceLibrary.Get<SoundEvent>( RumbleSoundPath );\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\t// One instance keeps the clock, on the host; everyone else only hears what it sends.\r\n\t\tif ( Scene.IsEditor || Current != this || !Networking.IsHost ) return;\r\n\r\n\t\tif ( !_scheduled )\r\n\t\t{\r\n\t\t\tSchedule();\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tif ( _nextShell > 0f ) return;\r\n\t\tSchedule();\r\n\r\n\t\tvar near = Game.Random.Float( 0f, 1f ) < NearChance;\r\n\t\tvar distance = near\r\n\t\t\t? Game.Random.Float( Math.Min( NearMin, NearMax ), Math.Max( NearMin, NearMax ) )\r\n\t\t\t: Game.Random.Float( Math.Min( FarMin, FarMax ), Math.Max( FarMin, FarMax ) );\r\n\t\tvar yaw = Game.Random.Float( 0f, 360f );\r\n\r\n\t\tShellRpc( yaw, distance, near );\r\n\t}\r\n\r\n\t/// <summary>A fresh random wait, from the Inspector values as they are right now.</summary>\r\n\tvoid Schedule()\r\n\t{\r\n\t\tvar min = Math.Min( IntervalMin, IntervalMax );\r\n\t\tvar max = Math.Max( IntervalMin, IntervalMax );\r\n\t\tvar wait = Game.Random.Float( min, max );\r\n\t\t_nextShell = wait;\r\n\t\t_scheduled = true;\r\n\t\tLog.Info( $\"TrenchShelling: next shell in {wait:0} s (interval {min:0} to {max:0})\" );\r\n\t}\r\n\r\n\t/// <summary>A shell lands at this yaw and distance from this machine's own camera: the sound there and a shake sized by the distance.</summary>\r\n\t[Rpc.Broadcast]\r\n\tvoid ShellRpc( float yaw, float distance, bool near )\r\n\t{\r\n\t\tvar camera = Scene.Camera;\r\n\t\tvar origin = camera.IsValid() ? camera.WorldPosition : WorldPosition;\r\n\t\tvar position = origin + Rotation.FromYaw( yaw ).Forward * distance;\r\n\r\n\t\tvar sound = near ? NearSound : FarSound;\r\n\t\tif ( sound is not null && Volume > 0f )\r\n\t\t{\r\n\t\t\tvar handle = Sound.Play( sound, position );\r\n\t\t\tif ( handle.IsValid() )\r\n\t\t\t{\r\n\t\t\t\thandle.Volume *= Volume;\r\n\t\t\t\thandle.TargetMixer = GolfMixers.Shelling ?? handle.TargetMixer;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( near && RumbleSound is not null && Volume > 0f )\r\n\t\t{\r\n\t\t\tvar rumble = Sound.Play( RumbleSound, position );\r\n\t\t\tif ( rumble.IsValid() )\r\n\t\t\t{\r\n\t\t\t\trumble.Volume *= Volume;\r\n\t\t\t\trumble.TargetMixer = GolfMixers.Shelling ?? rumble.TargetMixer;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tvar falloff = 1f - Math.Clamp( distance / Math.Max( 1f, ShakeRange ), 0f, 1f );\r\n\t\tvar amplitude = (near ? NearShake : FarShake) * falloff;\r\n\t\tvar duration = near ? NearShakeTime : FarShakeTime;\r\n\t\tLog.Info( $\"TrenchShelling: {( near ? \"near\" : \"far\" )} shell at {distance:0} units, yaw {yaw:0}, shake {amplitude:0.00} for {duration:0.0} s\" );\r\n\t\tif ( amplitude <= 0.01f || duration <= 0f ) return;\r\n\r\n\t\t// The shake lives on the one instance that applies it. A stronger shell replaces a weaker\r\n\t\t// shake in progress; a weaker one is dropped while a stronger one still runs.\r\n\t\tvar target = Current.IsValid() && Current.Scene == Scene ? Current : this;\r\n\t\ttarget.StartShake( amplitude, duration, near );\r\n\t}\r\n\r\n\tvoid StartShake( float amplitude, float duration, bool near )\r\n\t{\r\n\t\tif ( Remaining > 0f && _amplitude * Envelope( Time.Now - _started ) > amplitude ) return;\r\n\r\n\t\t_amplitude = amplitude;\r\n\t\t_duration = duration;\r\n\t\t_started = Time.Now;\r\n\t\t_near = near;\r\n\t\t_kickDirection = (Vector3.Random.WithZ( 0f ).Normal * 0.7f + Vector3.Down * 0.7f).Normal;\r\n\t\t_shell++;\r\n\t}\r\n\r\n\t/// <summary>Seconds of the current shake still to run, 0 when there is none.</summary>\r\n\tfloat Remaining => _duration <= 0f ? 0f : Math.Max( 0f, _duration - (Time.Now - _started) );\r\n\r\n\t/// <summary>\r\n\t/// Strength over the life of a shake, 0 to 1: a fast attack, then an exponential decay that is\r\n\t/// nearly gone by the end. Near shells hit at once and roll off; far ones swell in and fade.\r\n\t/// </summary>\r\n\tfloat Envelope( float age )\r\n\t{\r\n\t\tif ( age < 0f || _duration <= 0f || age >= _duration ) return 0f;\r\n\t\tvar attack = _near ? 0.04f : 0.2f;\r\n\t\tvar tau = _duration / 4f;\r\n\t\tif ( age < attack ) return age / attack;\r\n\t\treturn MathF.Exp( -(age - attack) / tau );\r\n\t}\r\n\r\n\t/// <summary>Three layered sines with unrelated rates: smooth, never repeating within a shake, no steps.</summary>\r\n\tstatic float Noise( float t, float seed )\r\n\t{\r\n\t\treturn MathF.Sin( t + seed ) * 0.55f + MathF.Sin( t * 1.83f + seed * 1.7f + 0.9f ) * 0.3f + MathF.Sin( t * 3.11f + seed * 2.3f + 2.1f ) * 0.15f;\r\n\t}\r\n\r\n\t/// <summary>The shake right now: a position offset in units and a tilt of the view.</summary>\r\n\tbool CurrentOffset( out Vector3 offset, out Rotation tilt )\r\n\t{\r\n\t\toffset = Vector3.Zero;\r\n\t\ttilt = Rotation.Identity;\r\n\t\tif ( Remaining <= 0f ) return false;\r\n\r\n\t\tvar age = Time.Now - _started;\r\n\t\tvar strength = _amplitude * Envelope( age );\r\n\t\tvar t = Time.Now * ShakeFrequency * MathF.PI * 2f;\r\n\r\n\t\toffset = new Vector3( Noise( t, 1f ), Noise( t, 2f ), Noise( t, 3f ) ) * strength;\r\n\r\n\t\t// The near shell's hard first knock, gone in a quarter of a second.\r\n\t\tif ( _near )\r\n\t\t\toffset += _kickDirection * _amplitude * MathF.Exp( -age / 0.12f );\r\n\r\n\t\tvar degrees = ShakeAngle * strength / Math.Max( 0.01f, NearShake );\r\n\t\ttilt = new Angles( Noise( t, 4f ) * degrees, Noise( t, 5f ) * degrees, Noise( t, 6f ) * degrees * 0.5f ).ToRotation();\r\n\t\treturn true;\r\n\t}\r\n\r\n\t// ------------------------------------------------------------ reaching the active camera\r\n\r\n\t// After the fly-over (100) and the free camera (200), so the offset lands on the finished view.\r\n\tint ICameraModifier.CameraOrder => 900;\r\n\r\n\tvoid ICameraModifier.ModifyCamera( CameraComponent camera, ref CameraView view )\r\n\t{\r\n\t\tif ( Current != this ) return;\r\n\t\tif ( !camera.IsValid() ) return;\r\n\r\n\t\t// Remember what the camera's own transform was when its view was composed.\r\n\t\t_composedCamera = camera;\r\n\t\t_composedPosition = camera.WorldPosition;\r\n\t\t_composedRotation = camera.WorldRotation;\r\n\t\t_composedThisTick = true;\r\n\r\n\t\tif ( !CurrentOffset( out var offset, out var tilt ) ) return;\r\n\t\tview.Position += offset;\r\n\t\tview.Rotation *= tilt;\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// End of the tick, after every PreRender: if the active camera's transform was rewritten\r\n\t/// after its view was composed (the fly-over, the free camera, the menu orbit), the engine\r\n\t/// renders from that transform, so the shake goes into the transform too.\r\n\t/// </summary>\r\n\tpublic void ApplyLate()\r\n\t{\r\n\t\tvar composed = _composedThisTick;\r\n\t\t_composedThisTick = false;\r\n\r\n\t\tvar camera = composed && _composedCamera.IsValid() ? _composedCamera : Scene.Camera;\r\n\t\tif ( !camera.IsValid() ) return;\r\n\t\tif ( !CurrentOffset( out var offset, out var tilt ) ) return;\r\n\r\n\t\tvar untouched = composed && camera == _composedCamera\r\n\t\t\t&& camera.WorldPosition == _composedPosition && camera.WorldRotation == _composedRotation;\r\n\r\n\t\tif ( untouched )\r\n\t\t{\r\n\t\t\tif ( _loggedShell != _shell )\r\n\t\t\t{\r\n\t\t\t\t_loggedShell = _shell;\r\n\t\t\t\tLog.Info( $\"TrenchShelling: shake on camera '{camera.GameObject.Name}' through its composed view\" );\r\n\t\t\t}\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tcamera.WorldPosition += offset;\r\n\t\tcamera.WorldRotation *= tilt;\r\n\t\t_rawCamera = camera;\r\n\t\t_rawOffset = offset;\r\n\t\t_rawTilt = tilt;\r\n\t\t_rawApplied = true;\r\n\r\n\t\tif ( _loggedShell != _shell )\r\n\t\t{\r\n\t\t\t_loggedShell = _shell;\r\n\t\t\tLog.Info( $\"TrenchShelling: shake on camera '{camera.GameObject.Name}' through its transform (rewritten after composition)\" );\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>Start of the next tick: take last tick's offset back out of the transform, so nothing accumulates.</summary>\r\n\tpublic void RestoreEarly()\r\n\t{\r\n\t\tif ( !_rawApplied ) return;\r\n\t\t_rawApplied = false;\r\n\t\tif ( !_rawCamera.IsValid() ) return;\r\n\r\n\t\t_rawCamera.WorldPosition -= _rawOffset;\r\n\t\t_rawCamera.WorldRotation *= _rawTilt.Inverse;\r\n\t}\r\n}\r\n\r\n/// <summary>\r\n/// Runs the shelling shake at the two ends of the tick: takes last tick's transform offset out at\r\n/// StartUpdate, before anything else, and puts this tick's in at FinishUpdate, after every\r\n/// component's PreRender, which is the last point before the frame renders.\r\n/// </summary>\r\npublic sealed class TrenchShellingSystem : GameObjectSystem<TrenchShellingSystem>\r\n{\r\n\tpublic TrenchShellingSystem( Scene scene ) : base( scene )\r\n\t{\r\n\t\tListen( Stage.StartUpdate, -1000, RestoreEarly, \"TrenchShelling.RestoreEarly\" );\r\n\t\tListen( Stage.FinishUpdate, 1000, ApplyLate, \"TrenchShelling.ApplyLate\" );\r\n\t}\r\n\r\n\tvoid RestoreEarly()\r\n\t{\r\n\t\tvar shelling = TrenchShelling.Current;\r\n\t\tif ( shelling.IsValid() && shelling.Scene == Scene ) shelling.RestoreEarly();\r\n\t}\r\n\r\n\tvoid ApplyLate()\r\n\t{\r\n\t\tvar shelling = TrenchShelling.Current;\r\n\t\tif ( shelling.IsValid() && shelling.Scene == Scene ) shelling.ApplyLate();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "KitModelWall.cs",
            "FileName": "KitModelWall.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// A wall made from a kit wall model (Blender-made, built along its X axis), stretched to a run and\r\n/// sheared up a slope: a parallelogram with vertical ends and its top and bottom parallel to the\r\n/// ground line, like the Kit Shape wall's Rise. The mesh is a copy of the model's vertices with\r\n/// the model's own materials, so a sloped wall looks like the level ones. Built on load and never\r\n/// saved (like Kit Shape), so the scene file only stores the four numbers.\r\n///\r\n/// Local frame: the run starts at the object's origin and goes along +X for Length (horizontal),\r\n/// rising by Rise; Line Y is the model's local Y that sits on that line (its bounds centre for a\r\n/// wall centred on the line, a side face for a wall standing beside it). The Kit Wall Builder and\r\n/// the hole's Add edge walls button make these for sloped runs.\r\n/// </summary>\r\n[Title( \"Kit Model Wall\" ), Icon( \"fence\" )]\r\npublic sealed class KitModelWall : Component, Component.ExecuteInEditor\r\n{\r\n\t/// <summary>The kit wall model to copy (models/kit/wall_100.vmdl).</summary>\r\n\t[Property] public Model Source { get; set; }\r\n\r\n\t/// <summary>Horizontal length of the run, in units.</summary>\r\n\t[Property] public float Length { get; set; } = 100f;\r\n\r\n\t/// <summary>Height difference over the run: the +x end sits this much higher.</summary>\r\n\t[Property] public float Rise { get; set; }\r\n\r\n\t/// <summary>The model's local Y that lies on the run line.</summary>\r\n\t[Property, Title( \"Line Y\" )] public float LineY { get; set; }\r\n\r\n\t[Property] public bool Collision { get; set; } = true;\r\n\r\n\t/// <summary>Material drawn instead of the model's own (the kit wall material); empty keeps the model's.</summary>\r\n\t[Property] public Material Material { get; set; }\r\n\r\n\t/// <summary>World units per texture repeat. The mesh gets box-projected texture coordinates at its stretched size, so the texture repeats along the run instead of stretching with it.</summary>\r\n\t[Property, Title( \"Texture Repeat\" )] public float TextureRepeat { get; set; } = 100f;\r\n\r\n\tModel _model;\r\n\tModel _builtSource;\r\n\tMaterial _builtMaterial;\r\n\tfloat _builtLength, _builtRise, _builtLineY, _builtRepeat;\r\n\tbool _builtCollision;\r\n\r\n\tprotected override void OnEnabled() => Rebuild();\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( _model is null || _builtSource != Source || _builtLength != Length || _builtRise != Rise || _builtLineY != LineY || _builtCollision != Collision || _builtMaterial != Material || _builtRepeat != TextureRepeat )\r\n\t\t\tRebuild();\r\n\t}\r\n\r\n\t/// <summary>How many kit model walls have been rebuilt since load (see golf_perf).</summary>\r\n\tpublic static int RebuildCount;\r\n\r\n\t/// <summary>Builds the sheared copy and puts it on this object's renderer and collider.</summary>\r\n\tpublic void Rebuild()\r\n\t{\r\n\t\tRebuildCount++;\r\n\t\t_builtSource = Source;\r\n\t\t_builtLength = Length;\r\n\t\t_builtRise = Rise;\r\n\t\t_builtLineY = LineY;\r\n\t\t_builtCollision = Collision;\r\n\t\t_builtMaterial = Material;\r\n\t\t_builtRepeat = TextureRepeat;\r\n\r\n\t\tif ( Source is null || Source.IsError ) return;\r\n\t\tvar sourceVertices = Source.GetVertices();\r\n\t\tvar sourceIndices = Source.GetIndices();\r\n\t\tif ( sourceVertices is null || sourceIndices is null || sourceVertices.Length == 0 || sourceIndices.Length < 3 ) return;\r\n\r\n\t\tvar bounds = Source.Bounds;\r\n\t\tvar length = MathF.Max( 1f, Length );\r\n\t\tvar stretch = length / MathF.Max( 1f, bounds.Size.x );\r\n\t\tvar slope = Rise / length;\r\n\r\n\t\t// Shear: x' = stretch * (x - minX), z' = (z - minZ) + slope * x'. Normals take the inverse\r\n\t\t// transpose, tangents the matrix itself, both renormalised.\r\n\t\tvar vertices = new List<SimpleVertex>( sourceVertices.Length );\r\n\t\tvar positions = new List<Vector3>( sourceVertices.Length );\r\n\t\tvar repeat = MathF.Max( 1f, TextureRepeat );\r\n\t\tforeach ( var v in sourceVertices )\r\n\t\t{\r\n\t\t\tvar x = (v.Position.x - bounds.Mins.x) * stretch;\r\n\t\t\tvar p = new Vector3( x, v.Position.y - LineY, v.Position.z - bounds.Mins.z + slope * x );\r\n\t\t\tvar n = new Vector3( v.Normal.x / stretch - slope * v.Normal.z, v.Normal.y, v.Normal.z ).Normal;\r\n\t\t\tvar t = new Vector3( stretch * v.Tangent.x, v.Tangent.y, slope * stretch * v.Tangent.x + v.Tangent.z ).Normal;\r\n\t\t\t// Box projection on the stretched mesh: the texture repeats every TextureRepeat units\r\n\t\t\t// along the run (and up the wall) however long the wall is.\r\n\t\t\tvar ax = MathF.Abs( n.x ); var ay = MathF.Abs( n.y ); var az = MathF.Abs( n.z );\r\n\t\t\tvar uv = az >= ax && az >= ay ? new Vector2( p.x, p.y ) : ax >= ay ? new Vector2( p.y, p.z ) : new Vector2( p.x, p.z );\r\n\t\t\tvertices.Add( new SimpleVertex( p, n, t, uv / repeat ) );\r\n\t\t\tpositions.Add( p );\r\n\t\t}\r\n\t\tvar indices = sourceIndices.Select( i => (int)i ).ToList();\r\n\r\n\t\tvar material = Source.Materials.FirstOrDefault() ?? Material.Load( \"prefabs/holekit/kit.vmat\" );\r\n\t\tvar mesh = new Mesh( material );\r\n\t\tmesh.CreateVertexBuffer( vertices.Count, vertices );\r\n\t\tmesh.CreateIndexBuffer( indices.Count, indices );\r\n\t\tmesh.Bounds = BBox.FromPoints( positions );\r\n\r\n\t\tvar builder = Model.Builder.AddMesh( mesh );\r\n\t\tif ( Collision )\r\n\t\t{\r\n\t\t\t// Physics gets a welded copy so the ball never meets an edge between a face's triangles.\r\n\t\t\tvar welded = new List<Vector3>();\r\n\t\t\tvar weldedIndices = new List<int>();\r\n\t\t\tvar lookup = new Dictionary<(int, int, int), int>();\r\n\t\t\tforeach ( var i in indices )\r\n\t\t\t{\r\n\t\t\t\tvar p = positions[i];\r\n\t\t\t\tvar key = ((int)MathF.Round( p.x * 16f ), (int)MathF.Round( p.y * 16f ), (int)MathF.Round( p.z * 16f ));\r\n\t\t\t\tif ( !lookup.TryGetValue( key, out var w ) )\r\n\t\t\t\t{\r\n\t\t\t\t\tw = welded.Count;\r\n\t\t\t\t\twelded.Add( p );\r\n\t\t\t\t\tlookup[key] = w;\r\n\t\t\t\t}\r\n\t\t\t\tweldedIndices.Add( w );\r\n\t\t\t}\r\n\t\t\tbuilder.AddCollisionMesh( welded, weldedIndices );\r\n\t\t}\r\n\t\t_model = builder.Create();\r\n\r\n\t\t// One renderer and one collider, driven from here and never saved (rebuilt on load).\r\n\t\tvar renderers = GameObject.GetComponents<ModelRenderer>( true ).ToList();\r\n\t\tvar renderer = renderers.FirstOrDefault() ?? GameObject.AddComponent<ModelRenderer>();\r\n\t\tforeach ( var spare in renderers.Skip( 1 ) ) spare.Destroy();\r\n\t\trenderer.Flags |= ComponentFlags.NotSaved;\r\n\t\trenderer.Model = _model;\r\n\t\trenderer.MaterialOverride = Material;\r\n\r\n\t\tvar colliders = GameObject.GetComponents<ModelCollider>( true ).ToList();\r\n\t\tvar collider = colliders.FirstOrDefault();\r\n\t\tforeach ( var spare in colliders.Skip( 1 ) ) spare.Destroy();\r\n\t\tif ( Collision )\r\n\t\t{\r\n\t\t\tcollider ??= GameObject.AddComponent<ModelCollider>();\r\n\t\t\tcollider.Flags |= ComponentFlags.NotSaved;\r\n\t\t\tcollider.Model = _model;\r\n\t\t\tcollider.Static = true;\r\n\t\t\tcollider.Surface = Surface.FindByName( GolfHoleCollision.SurfaceName ) ?? collider.Surface;\r\n\t\t\tcollider.Enabled = true;\r\n\t\t}\r\n\t\telse if ( collider.IsValid() )\r\n\t\t{\r\n\t\t\tcollider.Flags |= ComponentFlags.NotSaved;\r\n\t\t\tcollider.Enabled = false;\r\n\t\t}\r\n\r\n\t\t// Under a hole the hole's welded collider takes this piece in (and switches the collider off).\r\n\t\tGameObject.GetComponentInParent<GolfHoleCollision>( true )?.MarkDirty();\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "MudWaterRipple.cs",
            "FileName": "MudWaterRipple.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Ripples on a mud water plane. Every Interval (a random time between the min and max) a\r\n/// shockwave starts at a random point on the plane: a flat overlay quad carrying a ring texture\r\n/// (three concentric rings in its alpha and normal map, from Tools/make_mud_water.py) is placed on\r\n/// the surface, scaled up at Ring Speed until it reaches Max Radius, and fades to nothing on the\r\n/// way, about two seconds at the defaults. The overlay's fade is the renderer's Tint alpha; the\r\n/// complex shader has no attribute to drive a ripple inside the water material itself, so the\r\n/// overlay is the cheap route in this build. Nothing is networked, every machine ripples on its\r\n/// own; a small pool of overlays, no allocation per ripple. Put it on the object that renders the\r\n/// water plane: the plane's extent is that renderer's model bounds.\r\n///\r\n/// Between ripples the water also sways in place: the plane's material is copied at start and the\r\n/// copy's texture offset is moved round a tiny circle every frame (the complex shader has no\r\n/// wobble or animated normal option, only a straight scroll, so this is done from code). Nothing\r\n/// flows, the texture only drifts back and forth by a fraction of a texel.\r\n/// </summary>\r\n[Title( \"Mud Water Ripple\" ), Category( \"Golf\" ), Icon( \"water\" )]\r\npublic sealed class MudWaterRipple : Component\r\n{\r\n\t/// <summary>Shortest wait between ripples, seconds.</summary>\r\n\t[Property, Range( 0.5f, 60f ), Title( \"Interval Min\" )]\r\n\tpublic float IntervalMin { get; set; } = 5f;\r\n\r\n\t/// <summary>Longest wait between ripples, seconds.</summary>\r\n\t[Property, Range( 0.5f, 60f ), Title( \"Interval Max\" )]\r\n\tpublic float IntervalMax { get; set; } = 15f;\r\n\r\n\t/// <summary>How fast the rings spread, units per second.</summary>\r\n\t[Property, Range( 10f, 1000f ), Title( \"Ring Speed\" )]\r\n\tpublic float RingSpeed { get; set; } = 100f;\r\n\r\n\t/// <summary>How visible the rings are when they start, 0 to 1.</summary>\r\n\t[Property, Range( 0f, 1f ), Title( \"Ring Strength\" )]\r\n\tpublic float RingStrength { get; set; } = 0.8f;\r\n\r\n\t/// <summary>Radius the rings reach before they are gone, units.</summary>\r\n\t[Property, Range( 20f, 2000f ), Title( \"Max Radius\" )]\r\n\tpublic float MaxRadius { get; set; } = 200f;\r\n\r\n\t/// <summary>Ripples in flight at once; the pool size.</summary>\r\n\t[Property, Range( 1, 8 )]\r\n\tpublic int MaxRipples { get; set; } = 3;\r\n\r\n\t/// <summary>Sway the water texture in place between ripples.</summary>\r\n\t[Property, Group( \"Wobble\" )]\r\n\tpublic bool Wobble { get; set; } = true;\r\n\r\n\t/// <summary>How far the texture sways, in UV units; 0.002 is a fraction of a texel.</summary>\r\n\t[Property, Group( \"Wobble\" ), Range( 0f, 0.02f ), Title( \"Wobble Radius\" )]\r\n\tpublic float WobbleRadius { get; set; } = 0.002f;\r\n\r\n\t/// <summary>Seconds for one full sway round the circle.</summary>\r\n\t[Property, Group( \"Wobble\" ), Range( 1f, 30f ), Title( \"Wobble Period\" )]\r\n\tpublic float WobblePeriod { get; set; } = 6f;\r\n\r\n\tconst string QuadModel = \"models/mud_water/ripple_quad.vmdl\";\r\n\tconst string RingMaterial = \"textures/mud_water/ripple_ring.vmat\";\r\n\tconst float Lift = 0.75f;   // the overlay floats this far above the water so it never z-fights\r\n\r\n\tModelRenderer[] _overlays = Array.Empty<ModelRenderer>();\r\n\tfloat[] _born = Array.Empty<float>();\r\n\tint _next;\r\n\tfloat _nextAt;\r\n\tModelRenderer _plane;\r\n\tMaterial _planeMaterial;   // what the plane had before the wobble copy, put back on disable\r\n\tMaterial _wobble;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tvar model = Model.Load( QuadModel );\r\n\t\tvar material = Material.Load( RingMaterial );\r\n\t\tif ( model is null || material is null )\r\n\t\t{\r\n\t\t\tLog.Warning( $\"MudWaterRipple: missing {QuadModel} or {RingMaterial}\" );\r\n\t\t\treturn;\r\n\t\t}\r\n\r\n\t\tvar count = Math.Clamp( MaxRipples, 1, 8 );\r\n\t\t_overlays = new ModelRenderer[count];\r\n\t\t_born = new float[count];\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tvar go = new GameObject( GameObject, false, $\"ripple {i}\" );\r\n\t\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;\r\n\t\t\tvar renderer = go.AddComponent<ModelRenderer>();\r\n\t\t\trenderer.Model = model;\r\n\t\t\trenderer.MaterialOverride = material;\r\n\t\t\t_overlays[i] = renderer;\r\n\t\t}\r\n\r\n\t\t_nextAt = Time.Now + Wait();\r\n\r\n\t\t// The wobble works on a private copy of the plane's material, so the asset itself is untouched.\r\n\t\t_plane = GetComponent<ModelRenderer>();\r\n\t\tvar source = _plane?.MaterialOverride ?? _plane?.Model?.Materials.FirstOrDefault();\r\n\t\tif ( Wobble && source is not null )\r\n\t\t{\r\n\t\t\t_planeMaterial = _plane.MaterialOverride;\r\n\t\t\t_wobble = source.CreateCopy( \"mud water wobble\" );\r\n\t\t\t_plane.MaterialOverride = _wobble;\r\n\t\t}\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tforeach ( var overlay in _overlays )\r\n\t\t\tif ( overlay.IsValid() ) overlay.GameObject.Enabled = false;\r\n\r\n\t\tif ( _wobble is not null && _plane.IsValid() && _plane.MaterialOverride == _wobble )\r\n\t\t\t_plane.MaterialOverride = _planeMaterial;\r\n\t\t_wobble = null;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor || _overlays.Length == 0 ) return;\r\n\r\n\t\tvar now = Time.Now;\r\n\r\n\t\tif ( _wobble is not null )\r\n\t\t{\r\n\t\t\tvar angle = now / Math.Max( 0.1f, WobblePeriod ) * MathF.PI * 2f;\r\n\t\t\tvar radius = Wobble ? WobbleRadius : 0f;\r\n\t\t\t_wobble.Set( \"g_vTexCoordOffset\", new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) ) * radius );\r\n\t\t}\r\n\t\tvar life = MaxRadius / Math.Max( 1f, RingSpeed );\r\n\r\n\t\tfor ( var i = 0; i < _overlays.Length; i++ )\r\n\t\t{\r\n\t\t\tvar overlay = _overlays[i];\r\n\t\t\tif ( !overlay.IsValid() || !overlay.GameObject.Enabled ) continue;\r\n\r\n\t\t\tvar age = now - _born[i];\r\n\t\t\tif ( age >= life )\r\n\t\t\t{\r\n\t\t\t\toverlay.GameObject.Enabled = false;\r\n\t\t\t\tcontinue;\r\n\t\t\t}\r\n\r\n\t\t\tvar t = age / life;\r\n\t\t\tvar radius = Math.Max( 1f, RingSpeed * age );\r\n\t\t\toverlay.GameObject.WorldScale = new Vector3( radius, radius, 1f );\r\n\t\t\toverlay.Tint = Color.White.WithAlpha( RingStrength * (1f - t) * (1f - t) );\r\n\t\t}\r\n\r\n\t\tif ( now < _nextAt ) return;\r\n\t\t_nextAt = now + Wait();\r\n\t\tSpawn();\r\n\t}\r\n\r\n\tfloat Wait()\r\n\t{\r\n\t\tvar min = Math.Min( IntervalMin, IntervalMax );\r\n\t\tvar max = Math.Max( IntervalMin, IntervalMax );\r\n\t\treturn Game.Random.Float( min, max );\r\n\t}\r\n\r\n\t/// <summary>A ripple at a random point on the plane's top surface.</summary>\r\n\tvoid Spawn()\r\n\t{\r\n\t\tvar i = _next;\r\n\t\t_next = (_next + 1) % _overlays.Length;\r\n\t\tvar overlay = _overlays[i];\r\n\t\tif ( !overlay.IsValid() ) return;\r\n\r\n\t\tvar plane = GetComponent<ModelRenderer>();\r\n\t\tvar bounds = plane?.Model?.Bounds ?? new BBox( new Vector3( -100f, -100f, 0f ), new Vector3( 100f, 100f, 0f ) );\r\n\t\tvar local = new Vector3(\r\n\t\t\tGame.Random.Float( bounds.Mins.x, bounds.Maxs.x ),\r\n\t\t\tGame.Random.Float( bounds.Mins.y, bounds.Maxs.y ),\r\n\t\t\tbounds.Maxs.z );\r\n\t\tvar position = WorldTransform.PointToWorld( local ) + Vector3.Up * Lift;\r\n\r\n\t\toverlay.GameObject.WorldPosition = position;\r\n\t\toverlay.GameObject.WorldRotation = Rotation.FromYaw( Game.Random.Float( 0f, 360f ) );\r\n\t\toverlay.GameObject.WorldScale = new Vector3( 1f, 1f, 1f );\r\n\t\toverlay.Tint = Color.White.WithAlpha( RingStrength );\r\n\t\t_born[i] = Time.Now;\r\n\t\toverlay.GameObject.Enabled = true;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Editor/GolfHoleEdgeWalls.cs",
            "FileName": "GolfHoleEdgeWalls.cs",
            "PackageType": "library",
            "CodeKind": "Editor",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\nusing Editor;\nusing System;\nusing System.Collections.Generic;\nusing System.Linq;\n\n/// <summary>\n/// Inspector for Golf Hole Collision: the component's own rows plus two buttons. \"Add edge walls\"\n/// walls in the hole's outer boundary (every floor tile, ramp and cup tile edge with no neighbouring\n/// piece) with the kit post and wall models, placed exactly like the Kit Wall Builder would place\n/// them. \"Remove edge walls\" deletes only the pieces that button made. Editor only; both undo.\n/// </summary>\n[CustomEditor( typeof( GolfHoleCollision ) )]\npublic sealed class GolfHoleCollisionEditor : ComponentEditorWidget\n{\n\tpublic GolfHoleCollisionEditor( SerializedObject obj ) : base( obj )\n\t{\n\t\tLayout = Layout.Column();\n\n\t\tvar sheet = new Editor.ControlSheet();\n\t\tsheet.IncludePropertyNames = true;\n\t\tsheet.AddObject( obj, p => p.HasAttribute<PropertyAttribute>() );\n\t\tLayout.Add( sheet );\n\n\t\tvar buttons = Layout.AddRow();\n\t\tbuttons.Margin = 4f;\n\t\tbuttons.Spacing = 4f;\n\n\t\tvar add = new Editor.Button( \"Add edge walls\", \"fence\", this );\n\t\tadd.ToolTip = \"Posts and walls along every outer tile edge of this hole (kit post_10 and wall_100 models).\";\n\t\tadd.Clicked = () => GolfHoleEdgeWalls.Add( Hole );\n\t\tbuttons.Add( add );\n\n\t\tvar remove = new Editor.Button( \"Remove edge walls\", \"delete\", this );\n\t\tremove.ToolTip = \"Deletes the posts and walls that Add edge walls made under this hole.\";\n\t\tremove.Clicked = () => GolfHoleEdgeWalls.Remove( Hole );\n\t\tbuttons.Add( remove );\n\t}\n\n\tGolfHoleCollision Hole => SerializedObject.Targets?.OfType<GolfHoleCollision>().FirstOrDefault();\n}\n\n/// <summary>\n/// Finds the outer boundary of a hole's tile pieces and builds posts and walls along it. An edge is\n/// on the boundary when no other piece has the same edge (same two end points in 3D, so two ramps\n/// side by side join and a tile at another height does not). Where two boundary edges share the\n/// same footprint at different heights (a ramp's sloped side beside a flat tile, or a height step)\n/// only the higher one is walled: the lower one lies against the higher piece's side. The boundary\n/// edges are joined into loops, straight runs are merged, a post stands at every corner and one\n/// wall runs between posts; a sloped wall is sheared (Kit Model Wall) so it follows the surface.\n/// Walls stand on the tile with their outer face on the edge line, foot (if the model has one)\n/// towards the play side.\n/// </summary>\npublic static class GolfHoleEdgeWalls\n{\n\t/// <summary>Tag on every object these buttons create, so Remove touches nothing else.</summary>\n\tpublic const string Tag = \"golf_edgewall\";\n\n\tconst string WallModelPath = \"models/kit/wall_100.vmdl\";\n\tconst string PostModelPath = \"models/kit/post_10.vmdl\";\n\n\t/// <summary>Points closer than 1/4 unit are the same point.</summary>\n\tconst float Grid = 4f;\n\n\tpublic static void Add( GolfHoleCollision hole )\n\t{\n\t\tif ( !hole.IsValid() ) return;\n\t\tvar session = SceneEditorSession.Resolve( hole.GameObject ) ?? SceneEditorSession.Active;\n\t\tif ( session is null ) return;\n\n\t\tvar wallModel = KitWallBuilderTool.LoadKitModel( WallModelPath );\n\t\tvar postModel = KitWallBuilderTool.LoadKitModel( PostModelPath );\n\t\tif ( wallModel is null || postModel is null )\n\t\t{\n\t\t\tLog.Warning( $\"Add edge walls: {WallModelPath} or {PostModelPath} is missing\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar loops = BoundaryLoops( hole.GameObject );\n\t\tif ( loops.Count == 0 )\n\t\t{\n\t\t\tLog.Info( $\"Add edge walls: no tile edges found under {hole.GameObject.Name}\" );\n\t\t\treturn;\n\t\t}\n\n\t\tvar footSide = FootSide( wallModel );\n\t\tvar bounds = wallModel.Bounds;\n\t\tLog.Info( $\"Add edge walls: wall model bounds x {bounds.Mins.x}..{bounds.Maxs.x}, y {bounds.Mins.y}..{bounds.Maxs.y}, z {bounds.Mins.z}..{bounds.Maxs.z}; foot side {(footSide > 0 ? \"+y\" : footSide < 0 ? \"-y\" : \"none\")}\" );\n\n\t\tusing var sceneScope = session.Scene.Push();\n\t\tusing var undo = session.UndoScope( \"Add edge walls\" ).WithGameObjectCreations().Push();\n\n\t\tvar parent = hole.GameObject;\n\t\tvar posts = 0;\n\t\tvar walls = 0;\n\t\tforeach ( var loop in loops )\n\t\t{\n\t\t\tforeach ( var point in loop.Points )\n\t\t\t{\n\t\t\t\tvar post = KitWallBuilderTool.CreateModelObject( parent, \"Post\", postModel, point - KitWallBuilderTool.BottomCentre( postModel.Bounds ), Rotation.Identity, Vector3.One );\n\t\t\t\tpost.Tags.Add( Tag );\n\t\t\t\tposts++;\n\t\t\t}\n\n\t\t\tvar segments = loop.Closed ? loop.Points.Count : loop.Points.Count - 1;\n\t\t\tfor ( var i = 0; i < segments; i++ )\n\t\t\t{\n\t\t\t\tvar from = loop.Points[i];\n\t\t\t\tvar to = loop.Points[(i + 1) % loop.Points.Count];\n\t\t\t\tvar inward = loop.Inward[i];\n\t\t\t\tif ( (to - from).WithZ( 0f ).Length < 1f ) continue;\n\n\t\t\t\t// The wall stands on the tile: its outer face on the edge line, the play side (and the\n\t\t\t\t// foot, when the model has one) towards the tiles. The model's +Y is the run's left; the\n\t\t\t\t// run is reversed when the foot would end up outside.\n\t\t\t\tvar inwardOnLeft = Vector3.Dot( Rotation.LookAt( (to - from).WithZ( 0f ).Normal, Vector3.Up ).Left, inward ) > 0f;\n\t\t\t\tvar wantLeft = footSide >= 0;\n\t\t\t\tif ( inwardOnLeft != wantLeft ) (from, to) = (to, from);\n\t\t\t\tvar lineY = wantLeft ? bounds.Mins.y : bounds.Maxs.y;\n\n\t\t\t\tvar wall = KitWallBuilderTool.PlaceWallPiece( parent, wallModel, from, to, lineY );\n\t\t\t\tif ( wall is null ) continue;\n\t\t\t\twall.Tags.Add( Tag );\n\t\t\t\twalls++;\n\t\t\t}\n\t\t}\n\n\t\thole.MarkDirty();\n\t\tLog.Info( $\"Add edge walls: {hole.GameObject.Name}: {posts} posts and {walls} walls along {loops.Count} loop(s)\" );\n\t}\n\n\tpublic static void Remove( GolfHoleCollision hole )\n\t{\n\t\tif ( !hole.IsValid() ) return;\n\t\tvar session = SceneEditorSession.Resolve( hole.GameObject ) ?? SceneEditorSession.Active;\n\t\tif ( session is null ) return;\n\n\t\tvar made = hole.GameObject.Children.Where( go => go.IsValid() && go.Tags.Has( Tag ) ).ToList();\n\t\tif ( made.Count == 0 )\n\t\t{\n\t\t\tLog.Info( $\"Remove edge walls: nothing to remove under {hole.GameObject.Name}\" );\n\t\t\treturn;\n\t\t}\n\n\t\tusing var sceneScope = session.Scene.Push();\n\t\tusing var undo = session.UndoScope( \"Remove edge walls\" ).WithGameObjectDestructions( made ).Push();\n\t\tforeach ( var go in made ) go.Destroy();\n\n\t\thole.MarkDirty();\n\t\tLog.Info( $\"Remove edge walls: {hole.GameObject.Name}: removed {made.Count} pieces\" );\n\t}\n\n\t/// <summary>\n\t/// Which side of the wall model is wider at the base than higher up: +1 for +Y, -1 for -Y,\n\t/// 0 for a wall with the same width all the way up.\n\t/// </summary>\n\tstatic int FootSide( Model model )\n\t{\n\t\tvar vertices = model.GetVertices();\n\t\tif ( vertices is null || vertices.Length == 0 ) return 0;\n\t\tvar bounds = model.Bounds;\n\t\tvar baseTop = bounds.Mins.z + 1f;\n\t\tvar upperFrom = bounds.Mins.z + bounds.Size.z * 0.5f;\n\t\tvar baseMin = float.MaxValue; var baseMax = float.MinValue;\n\t\tvar upperMin = float.MaxValue; var upperMax = float.MinValue;\n\t\tforeach ( var v in vertices )\n\t\t{\n\t\t\tif ( v.Position.z <= baseTop ) { baseMin = MathF.Min( baseMin, v.Position.y ); baseMax = MathF.Max( baseMax, v.Position.y ); }\n\t\t\tif ( v.Position.z >= upperFrom ) { upperMin = MathF.Min( upperMin, v.Position.y ); upperMax = MathF.Max( upperMax, v.Position.y ); }\n\t\t}\n\t\tif ( baseMin == float.MaxValue || upperMin == float.MaxValue ) return 0;\n\t\tvar plus = baseMax - upperMax;\n\t\tvar minus = upperMin - baseMin;\n\t\tif ( plus > 0.5f && plus >= minus ) return 1;\n\t\tif ( minus > 0.5f ) return -1;\n\t\treturn 0;\n\t}\n\n\t// ---------------------------------------------------------------- boundary\n\n\tsealed class Loop\n\t{\n\t\tpublic List<Vector3> Points = new();\n\t\t/// <summary>Horizontal direction into the tiles for the segment starting at each point.</summary>\n\t\tpublic List<Vector3> Inward = new();\n\t\tpublic bool Closed;\n\t}\n\n\trecord struct Edge( Key A, Key B, Vector3 Inward, float Height );\n\n\t/// <summary>\n\t/// The boundary of the tile pieces as point loops in world space, corners only (straight runs\n\t/// merged), each with the inward direction of every segment.\n\t/// </summary>\n\tstatic List<Loop> BoundaryLoops( GameObject holeObject )\n\t{\n\t\t// Every top-surface edge of every piece, counted by its two end points in 3D.\n\t\tvar edges = new Dictionary<(Key, Key), (Edge edge, int count)>();\n\t\tforeach ( var renderer in holeObject.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )\n\t\t{\n\t\t\tvar go = renderer.GameObject;\n\t\t\tif ( !go.IsValid() || !go.Enabled || go == holeObject || go.Tags.Has( Tag ) ) continue;\n\t\t\tvar corners = TopCorners( renderer.Model );\n\t\t\tif ( corners is null ) continue;\n\n\t\t\tvar world = corners.Select( c => go.WorldTransform.PointToWorld( c ) ).ToArray();\n\t\t\tvar centre = go.WorldTransform.PointToWorld( Vector3.Zero );\n\t\t\tfor ( var i = 0; i < world.Length; i++ )\n\t\t\t{\n\t\t\t\tvar a = world[i];\n\t\t\t\tvar b = world[(i + 1) % world.Length];\n\t\t\t\tvar ka = Key.Of( a );\n\t\t\t\tvar kb = Key.Of( b );\n\t\t\t\tvar key = ka.CompareTo( kb ) <= 0 ? (ka, kb) : (kb, ka);\n\t\t\t\tvar inward = (centre - (a + b) * 0.5f).WithZ( 0f ).Normal;\n\t\t\t\tedges[key] = edges.TryGetValue( key, out var e )\n\t\t\t\t\t? (e.edge, e.count + 1)\n\t\t\t\t\t: (new Edge( key.Item1, key.Item2, inward, key.Item1.Point.z + key.Item2.Point.z ), 1);\n\t\t\t}\n\t\t}\n\n\t\t// Boundary edges: used by one piece only. Of the edges sharing one footprint at different\n\t\t// heights only the highest is kept: the lower lies against the higher piece's side.\n\t\tvar open = edges.Values.Where( e => e.count == 1 ).Select( e => e.edge )\n\t\t\t.GroupBy( e => (e.A.Flat, e.B.Flat) )\n\t\t\t.Select( g => g.OrderByDescending( e => e.Height ).First() )\n\t\t\t.ToList();\n\n\t\tvar byPoint = new Dictionary<Key, List<int>>();\n\t\tfor ( var i = 0; i < open.Count; i++ )\n\t\t{\n\t\t\tforeach ( var end in new[] { open[i].A, open[i].B } )\n\t\t\t{\n\t\t\t\tif ( !byPoint.TryGetValue( end, out var list ) ) byPoint[end] = list = new List<int>();\n\t\t\t\tlist.Add( i );\n\t\t\t}\n\t\t}\n\n\t\t// Walk the edges into chains; a chain that comes back to its first point is a closed loop.\n\t\tvar used = new bool[open.Count];\n\t\tvar loops = new List<Loop>();\n\t\tfor ( var start = 0; start < open.Count; start++ )\n\t\t{\n\t\t\tif ( used[start] ) continue;\n\t\t\tused[start] = true;\n\t\t\tvar chain = new List<Key> { open[start].A, open[start].B };\n\t\t\tvar inward = new List<Vector3> { open[start].Inward };\n\t\t\tvar closed = false;\n\t\t\twhile ( true )\n\t\t\t{\n\t\t\t\tvar tail = chain[^1];\n\t\t\t\tvar next = byPoint[tail].FirstOrDefault( i => !used[i], -1 );\n\t\t\t\tif ( next < 0 ) break;\n\t\t\t\tused[next] = true;\n\t\t\t\tvar other = open[next].A.Equals( tail ) ? open[next].B : open[next].A;\n\t\t\t\tinward.Add( open[next].Inward );\n\t\t\t\tif ( other.Equals( chain[0] ) ) { closed = true; break; }\n\t\t\t\tchain.Add( other );\n\t\t\t}\n\t\t\tloops.Add( MergeStraight( chain.Select( k => k.Point ).ToList(), inward, closed ) );\n\t\t}\n\t\treturn loops;\n\t}\n\n\t/// <summary>Corners of a piece's top surface in piece space, in order around it; null for pieces that are not tiles.</summary>\n\tstatic Vector3[] TopCorners( Model model )\n\t{\n\t\tvar name = model?.Name ?? \"\";\n\t\tif ( name.Contains( \"ramp\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn new[] { new Vector3( -100, -100, 0 ), new Vector3( 100, -100, KitShape.RampRise ), new Vector3( 100, 100, KitShape.RampRise ), new Vector3( -100, 100, 0 ) };\n\t\t// The kit floor tile, the cup tiles, the trench cup tile and every trench floor piece (floor,\n\t\t// floor_corner, floor_end, floor_mud_pit, floor_t_tile, floor_x_tile): 200 x 200 tiles, top at z 0.\n\t\tif ( name.Contains( \"floor\", StringComparison.OrdinalIgnoreCase ) || name.Contains( \"cup_\", StringComparison.OrdinalIgnoreCase ) || name.Contains( \"trench_hole\", StringComparison.OrdinalIgnoreCase ) )\n\t\t\treturn new[] { new Vector3( -100, -100, 0 ), new Vector3( 100, -100, 0 ), new Vector3( 100, 100, 0 ), new Vector3( -100, 100, 0 ) };\n\t\treturn null;\n\t}\n\n\t/// <summary>\n\t/// Drops every point where the direction does not change, so one wall covers a straight run.\n\t/// Segment i runs from point i to point i + 1 (back to 0 on a closed loop) with inward[i].\n\t/// </summary>\n\tstatic Loop MergeStraight( List<Vector3> points, List<Vector3> inward, bool closed )\n\t{\n\t\tvar loop = new Loop { Closed = closed };\n\t\tif ( points.Count < 3 )\n\t\t{\n\t\t\tloop.Points = points;\n\t\t\tloop.Inward = inward;\n\t\t\treturn loop;\n\t\t}\n\t\tfor ( var i = 0; i < points.Count; i++ )\n\t\t{\n\t\t\tvar isEnd = !closed && (i == 0 || i == points.Count - 1);\n\t\t\tif ( !isEnd )\n\t\t\t{\n\t\t\t\tvar prev = points[(i - 1 + points.Count) % points.Count];\n\t\t\t\tvar next = points[(i + 1) % points.Count];\n\t\t\t\tif ( Vector3.Dot( (points[i] - prev).Normal, (next - points[i]).Normal ) >= 0.9999f ) continue;\n\t\t\t}\n\t\t\tloop.Points.Add( points[i] );\n\t\t\tloop.Inward.Add( i < inward.Count ? inward[i] : inward[^1] );\n\t\t}\n\t\treturn loop;\n\t}\n\n\t/// <summary>A world point snapped to the grid, usable as a dictionary key.</summary>\n\treadonly record struct Key( int X, int Y, int Z ) : IComparable<Key>\n\t{\n\t\tpublic static Key Of( Vector3 p ) => new( (int)MathF.Round( p.x * Grid ), (int)MathF.Round( p.y * Grid ), (int)MathF.Round( p.z * Grid ) );\n\t\tpublic Vector3 Point => new( X / Grid, Y / Grid, Z / Grid );\n\t\tpublic (int, int) Flat => (X, Y);\n\t\tpublic int CompareTo( Key other ) => X != other.X ? X.CompareTo( other.X ) : Y != other.Y ? Y.CompareTo( other.Y ) : Z.CompareTo( other.Z );\n\t}\n}\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/GolfMixers.cs",
            "FileName": "GolfMixers.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing Sandbox.Audio;\r\n\r\n/// <summary>\r\n/// The game's own mixers, one per kind of sound, made under the master mixer the first time they\r\n/// are asked for and found again by name after that (so a hotload or a scene change never makes a\r\n/// second set). Each source sets its handles' TargetMixer to the right one; the guns stay on the\r\n/// default mixer. <see cref=\"GolfAudioZone\"/> filters and ducks these mixers.\r\n/// </summary>\r\npublic static class GolfMixers\r\n{\r\n\tpublic const string MusicName = \"Golf Music\";\r\n\tpublic const string AmbienceName = \"Golf Ambience\";\r\n\tpublic const string SoundsName = \"Golf Sounds\";\r\n\tpublic const string ShellingName = \"Trench Shelling\";\r\n\t/// <summary>The distraction hit's ping and ear ring: never muffled, so a hit is always heard.</summary>\r\n\tpublic const string HitName = \"Golf Hit\";\r\n\r\n\tpublic static Mixer Music => Get( MusicName );\r\n\tpublic static Mixer Ambience => Get( AmbienceName );\r\n\tpublic static Mixer Sounds => Get( SoundsName );\r\n\tpublic static Mixer Shelling => Get( ShellingName );\r\n\tpublic static Mixer Hit => Get( HitName );\r\n\r\n\t/// <summary>The mixer with this name under the master, made if it is not there yet. Null before the audio engine is up.</summary>\r\n\tpublic static Mixer Get( string name )\r\n\t{\r\n\t\tvar master = Mixer.Master;\r\n\t\tif ( master is null ) return null;\r\n\r\n\t\tvar mixer = Mixer.FindMixerByName( name );\r\n\t\tif ( mixer is not null ) return mixer;\r\n\r\n\t\tmixer = master.AddChild();\r\n\t\tmixer.Name = name;\r\n\t\treturn mixer;\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "Code/TrenchFire.cs",
            "FileName": "TrenchFire.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\n\r\n/// <summary>\r\n/// Decorative machine-gun fire for the trench map: bursts of glowing tracers fly from a random\r\n/// point on the Start line to a random point on the End line, with a muzzle flash where they leave\r\n/// and a dust puff where they land, and a distant, softened shot sound for every round. Purely a\r\n/// show: nothing is traced against players or balls, nothing takes damage, nothing is networked.\r\n/// Every machine runs its own with its own random timing.\r\n///\r\n/// The two lines are each two child objects (Start A/B, End A/B) that you move with the normal\r\n/// move gizmo; they are created the first time the component is seen in the editor if missing.\r\n/// The lines and the handles are drawn as gizmos. The component's own Enabled checkbox turns the\r\n/// gun off. Small pool of tracers and flash lights, nothing allocated per shot but the engine's\r\n/// own sound handle and the surface's impact particle.\r\n/// </summary>\r\n[Title( \"Trench Fire\" ), Category( \"Golf\" ), Icon( \"whatshot\" )]\r\npublic sealed class TrenchFire : Component\r\n{\r\n\t/// <summary>One end of the line the tracers leave from.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"Start A\" )]\r\n\tpublic GameObject StartA { get; set; }\r\n\r\n\t/// <summary>The other end of the line the tracers leave from.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"Start B\" )]\r\n\tpublic GameObject StartB { get; set; }\r\n\r\n\t/// <summary>One end of the line the tracers land on.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"End A\" )]\r\n\tpublic GameObject EndA { get; set; }\r\n\r\n\t/// <summary>The other end of the line the tracers land on.</summary>\r\n\t[Property, Group( \"Lines\" ), Title( \"End B\" )]\r\n\tpublic GameObject EndB { get; set; }\r\n\r\n\t/// <summary>Random scatter around the landing point, in units.</summary>\r\n\t[Property, Group( \"Lines\" ), Range( 0f, 300f )]\r\n\tpublic float Spread { get; set; } = 40f;\r\n\r\n\t/// <summary>Bursts per minute.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 0.1f, 60f ), Title( \"Rate (bursts per minute)\" )]\r\n\tpublic float Rate { get; set; } = 6f;\r\n\r\n\t/// <summary>Fewest shots in a burst.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1, 60 ), Title( \"Burst Length Min\" )]\r\n\tpublic int BurstMin { get; set; } = 8;\r\n\r\n\t/// <summary>Most shots in a burst.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1, 60 ), Title( \"Burst Length Max\" )]\r\n\tpublic int BurstMax { get; set; } = 15;\r\n\r\n\t/// <summary>Shots per second inside a burst.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 1f, 30f )]\r\n\tpublic float ShotsPerSecond { get; set; } = 12f;\r\n\r\n\t/// <summary>How much the pause between bursts varies, 0 for a metronome, 1 for anything from no pause to double.</summary>\r\n\t[Property, Group( \"Timing\" ), Range( 0f, 1f )]\r\n\tpublic float PauseVariance { get; set; } = 0.5f;\r\n\r\n\t/// <summary>How fast a tracer flies, units per second.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 500f, 20000f )]\r\n\tpublic float TracerSpeed { get; set; } = 6000f;\r\n\r\n\t/// <summary>Colour of the tracer streak, the flash and its light.</summary>\r\n\t[Property, Group( \"Tracer\" )]\r\n\tpublic Color TracerColour { get; set; } = new Color( 1f, 0.55f, 0.15f );\r\n\r\n\t/// <summary>Length of the streak, in units.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 10f, 1000f )]\r\n\tpublic float TracerLength { get; set; } = 160f;\r\n\r\n\t/// <summary>Width of the streak at its bright end, in units.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 0.5f, 10f )]\r\n\tpublic float TracerWidth { get; set; } = 2.5f;\r\n\r\n\t/// <summary>How much brighter than the colour the streak glows.</summary>\r\n\t[Property, Group( \"Tracer\" ), Range( 1f, 8f )]\r\n\tpublic float Glow { get; set; } = 3f;\r\n\r\n\t/// <summary>A short flash of light at the start point for every shot.</summary>\r\n\t[Property, Group( \"Effects\" )]\r\n\tpublic bool MuzzleFlash { get; set; } = true;\r\n\r\n\t/// <summary>The surface's bullet impact particle where a tracer lands.</summary>\r\n\t[Property, Group( \"Effects\" )]\r\n\tpublic bool DustPuff { get; set; } = true;\r\n\r\n\t/// <summary>The distant shot, played at the start point for every round. Empty uses sounds/trench_fire.</summary>\r\n\t[Property, Group( \"Sound\" )]\r\n\tpublic SoundEvent ShotSound { get; set; }\r\n\r\n\t/// <summary>Volume of the shots, 0 to 1.</summary>\r\n\t[Property, Group( \"Sound\" ), Range( 0f, 1f )]\r\n\tpublic float Volume { get; set; } = 0.35f;\r\n\r\n\tconst string DefaultSound = \"sounds/trench_fire.sound\";\r\n\tconst float FlashSeconds = 0.05f;\r\n\tconst float FlashRadius = 160f;\r\n\r\n\tstruct Landing\r\n\t{\r\n\t\tpublic float At;        // Time.Now the tracer arrives; 0 for none pending\r\n\t\tpublic Vector3 Position;\r\n\t\tpublic Vector3 Normal;\r\n\t\tpublic Surface Surface;\r\n\t}\r\n\r\n\tTracerEffect[] _tracers = Array.Empty<TracerEffect>();\r\n\tLanding[] _landings = Array.Empty<Landing>();\r\n\tPointLight[] _flashes = Array.Empty<PointLight>();\r\n\tfloat[] _flashOff = Array.Empty<float>();\r\n\tint _nextTracer;\r\n\tint _nextFlash;\r\n\tSurface _defaultSurface;\r\n\tColor _builtColour;\r\n\tfloat _builtWidth;\r\n\r\n\tfloat _nextBurstAt;\r\n\tfloat _nextShotAt;\r\n\tint _shotsLeft;\r\n\r\n\tprotected override void OnStart()\r\n\t{\r\n\t\tEnsureHandles();\r\n\t\tShotSound ??= ResourceLibrary.Get<SoundEvent>( DefaultSound );\r\n\t\t_defaultSurface = Surface.FindByName( \"default\" );\r\n\t\tBuildPool();\r\n\t\t_nextBurstAt = Time.Now + NextPause() * Game.Random.Float( 0f, 1f );\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\t_shotsLeft = 0;\r\n\t\tfor ( var i = 0; i < _landings.Length; i++ ) _landings[i].At = 0f;\r\n\t\tforeach ( var flash in _flashes ) if ( flash.IsValid() ) flash.Enabled = false;\r\n\t}\r\n\r\n\tprotected override void OnUpdate()\r\n\t{\r\n\t\tif ( Scene.IsEditor ) return;\r\n\t\tif ( _tracers.Length == 0 ) return;\r\n\r\n\t\tvar now = Time.Now;\r\n\r\n\t\t// Flashes go out, landings puff.\r\n\t\tfor ( var i = 0; i < _flashes.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( _flashOff[i] > 0f && now >= _flashOff[i] )\r\n\t\t\t{\r\n\t\t\t\t_flashOff[i] = 0f;\r\n\t\t\t\tif ( _flashes[i].IsValid() ) _flashes[i].Enabled = false;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tfor ( var i = 0; i < _landings.Length; i++ )\r\n\t\t{\r\n\t\t\tif ( _landings[i].At <= 0f || now < _landings[i].At ) continue;\r\n\t\t\t_landings[i].At = 0f;\r\n\t\t\tif ( DustPuff )\r\n\t\t\t\tBaseCombatWeapon.ImpactPrefab( null, _landings[i].Surface, _landings[i].Position, _landings[i].Normal );\r\n\t\t}\r\n\r\n\t\t// Bursts.\r\n\t\tif ( _shotsLeft <= 0 )\r\n\t\t{\r\n\t\t\tif ( now < _nextBurstAt ) return;\r\n\t\t\t_shotsLeft = Game.Random.Int( Math.Min( BurstMin, BurstMax ), Math.Max( BurstMin, BurstMax ) );\r\n\t\t\t_nextShotAt = now;\r\n\t\t\t_nextBurstAt = now + NextPause();\r\n\t\t}\r\n\r\n\t\tif ( now < _nextShotAt ) return;\r\n\t\t_nextShotAt = now + 1f / Math.Max( 1f, ShotsPerSecond );\r\n\t\t_shotsLeft--;\r\n\t\tFire();\r\n\t}\r\n\r\n\tfloat NextPause()\r\n\t{\r\n\t\tvar mean = 60f / Math.Max( 0.1f, Rate );\r\n\t\treturn mean * (1f + Game.Random.Float( -PauseVariance, PauseVariance ));\r\n\t}\r\n\r\n\tvoid Fire()\r\n\t{\r\n\t\tif ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;\r\n\r\n\t\tvar start = Vector3.Lerp( StartA.WorldPosition, StartB.WorldPosition, Game.Random.Float( 0f, 1f ) );\r\n\t\tvar end = Vector3.Lerp( EndA.WorldPosition, EndB.WorldPosition, Game.Random.Float( 0f, 1f ) )\r\n\t\t\t+ Vector3.Random * Spread;\r\n\r\n\t\t// Where it lands: the ground near the end point, for the puff's surface and facing.\r\n\t\tvar landing = new Landing { Position = end, Normal = Vector3.Up, Surface = _defaultSurface };\r\n\t\tvar trace = Scene.Trace.Ray( end + Vector3.Up * 48f, end + Vector3.Down * 96f ).WithoutTags( \"player\", \"golfball\" ).Run();\r\n\t\tif ( trace.Hit )\r\n\t\t{\r\n\t\t\tlanding.Position = trace.HitPosition;\r\n\t\t\tlanding.Normal = trace.Normal;\r\n\t\t\tlanding.Surface = trace.Surface ?? _defaultSurface;\r\n\t\t}\r\n\r\n\t\tvar distance = start.Distance( end );\r\n\t\tlanding.At = Time.Now + distance / Math.Max( 1f, TracerSpeed );\r\n\r\n\t\t// Tracer: the pool's oldest one restarts from the new start point.\r\n\t\tvar i = _nextTracer;\r\n\t\t_nextTracer = (_nextTracer + 1) % _tracers.Length;\r\n\t\tvar tracer = _tracers[i];\r\n\t\tif ( tracer.IsValid() )\r\n\t\t{\r\n\t\t\tif ( _builtColour != TracerColour || _builtWidth != TracerWidth ) BuildLook();\r\n\t\t\ttracer.Enabled = false;\r\n\t\t\ttracer.WorldPosition = start;\r\n\t\t\ttracer.EndPoint = end;\r\n\t\t\ttracer.DistancePerSecond = TracerSpeed;\r\n\t\t\ttracer.Length = TracerLength;\r\n\t\t\ttracer.Enabled = true;\r\n\t\t}\r\n\t\t_landings[i] = landing;\r\n\r\n\t\t// Muzzle flash.\r\n\t\tif ( MuzzleFlash && _flashes.Length > 0 )\r\n\t\t{\r\n\t\t\tvar f = _nextFlash;\r\n\t\t\t_nextFlash = (_nextFlash + 1) % _flashes.Length;\r\n\t\t\tvar flash = _flashes[f];\r\n\t\t\tif ( flash.IsValid() )\r\n\t\t\t{\r\n\t\t\t\tflash.WorldPosition = start + (end - start).Normal * 12f;\r\n\t\t\t\tflash.LightColor = TracerColour * Glow;\r\n\t\t\t\tflash.Radius = FlashRadius;\r\n\t\t\t\tflash.Enabled = true;\r\n\t\t\t\t_flashOff[f] = Time.Now + FlashSeconds;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\t// The distant shot.\r\n\t\tif ( ShotSound is not null && Volume > 0f )\r\n\t\t{\r\n\t\t\tvar handle = Sound.Play( ShotSound, start );\r\n\t\t\tif ( handle.IsValid() ) handle.Volume = Volume;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// Enough tracers to cover the longest flight at the burst rate, and a couple of flash lights.\r\n\t/// Pool objects are hidden children that are never saved or networked.\r\n\t/// </summary>\r\n\tvoid BuildPool()\r\n\t{\r\n\t\tvar longest = 0f;\r\n\t\tif ( StartA.IsValid() && StartB.IsValid() && EndA.IsValid() && EndB.IsValid() )\r\n\t\t{\r\n\t\t\tlongest = Math.Max( Math.Max( StartA.WorldPosition.Distance( EndA.WorldPosition ), StartA.WorldPosition.Distance( EndB.WorldPosition ) ),\r\n\t\t\t\tMath.Max( StartB.WorldPosition.Distance( EndA.WorldPosition ), StartB.WorldPosition.Distance( EndB.WorldPosition ) ) );\r\n\t\t}\r\n\t\tvar flight = (longest + Spread + TracerLength) / Math.Max( 1f, TracerSpeed );\r\n\t\tvar count = Math.Clamp( (int)MathF.Ceiling( flight * ShotsPerSecond ) + 2, 4, 32 );\r\n\r\n\t\t_tracers = new TracerEffect[count];\r\n\t\t_landings = new Landing[count];\r\n\t\tfor ( var i = 0; i < count; i++ )\r\n\t\t{\r\n\t\t\tvar go = new GameObject( GameObject, false, $\"tracer {i}\" );\r\n\t\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;\r\n\t\t\tvar tracer = go.AddComponent<TracerEffect>( false );\r\n\t\t\ttracer.StartDistance = 0f;\r\n\t\t\ttracer.Opaque = false;\r\n\t\t\ttracer.CastShadows = false;\r\n\t\t\ttracer.EnableLight = false;\r\n\t\t\t_tracers[i] = tracer;\r\n\t\t\tgo.Enabled = true;\r\n\t\t}\r\n\t\tBuildLook();\r\n\r\n\t\t_flashes = new PointLight[2];\r\n\t\t_flashOff = new float[2];\r\n\t\tfor ( var i = 0; i < _flashes.Length; i++ )\r\n\t\t{\r\n\t\t\tvar go = new GameObject( GameObject, false, $\"flash {i}\" );\r\n\t\t\tgo.Flags |= GameObjectFlags.NotSaved | GameObjectFlags.NotNetworked | GameObjectFlags.Hidden;\r\n\t\t\tvar light = go.AddComponent<PointLight>( false );\r\n\t\t\tlight.Shadows = false;\r\n\t\t\tlight.Radius = FlashRadius;\r\n\t\t\tlight.Attenuation = 2f;\r\n\t\t\t_flashes[i] = light;\r\n\t\t\tgo.Enabled = true;\r\n\t\t}\r\n\t}\r\n\r\n\tvoid BuildLook()\r\n\t{\r\n\t\t_builtColour = TracerColour;\r\n\t\t_builtWidth = TracerWidth;\r\n\t\tvar head = TracerColour * Glow;\r\n\t\thead.a = 1f;\r\n\t\tvar colour = new Gradient( new Gradient.ColorFrame( 0f, head ), new Gradient.ColorFrame( 0.35f, TracerColour ), new Gradient.ColorFrame( 1f, TracerColour.WithAlpha( 0f ) ) );\r\n\t\tvar width = new Curve( new Curve.Frame( 0f, TracerWidth ), new Curve.Frame( 1f, 0f ) );\r\n\t\tforeach ( var tracer in _tracers )\r\n\t\t{\r\n\t\t\tif ( !tracer.IsValid() ) continue;\r\n\t\t\ttracer.LineColor = colour;\r\n\t\t\ttracer.LineWidth = width;\r\n\t\t}\r\n\t}\r\n\r\n\t/// <summary>\r\n\t/// The four handle objects: found by name under this object, or made with default spots.\r\n\t/// </summary>\r\n\tvoid EnsureHandles()\r\n\t{\r\n\t\tStartA = Handle( StartA, \"Start A\", new Vector3( 0f, -150f, 40f ) );\r\n\t\tStartB = Handle( StartB, \"Start B\", new Vector3( 0f, 150f, 40f ) );\r\n\t\tEndA = Handle( EndA, \"End A\", new Vector3( 1200f, -250f, 0f ) );\r\n\t\tEndB = Handle( EndB, \"End B\", new Vector3( 1200f, 250f, 0f ) );\r\n\t}\r\n\r\n\tGameObject Handle( GameObject current, string name, Vector3 localPosition )\r\n\t{\r\n\t\tif ( current.IsValid() ) return current;\r\n\r\n\t\tforeach ( var child in GameObject.Children )\r\n\t\t\tif ( child.IsValid() && child.Name == name ) return child;\r\n\r\n\t\tvar go = new GameObject( GameObject, true, name );\r\n\t\tgo.LocalPosition = localPosition;\r\n\t\treturn go;\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tEnsureHandles();\r\n\t\tif ( !StartA.IsValid() || !StartB.IsValid() || !EndA.IsValid() || !EndB.IsValid() ) return;\r\n\r\n\t\tvar alpha = Gizmo.IsSelected ? 1f : (Gizmo.IsHovered ? 0.6f : 0.3f);\r\n\t\tvar local = WorldTransform;\r\n\t\tvar sa = local.PointToLocal( StartA.WorldPosition );\r\n\t\tvar sb = local.PointToLocal( StartB.WorldPosition );\r\n\t\tvar ea = local.PointToLocal( EndA.WorldPosition );\r\n\t\tvar eb = local.PointToLocal( EndB.WorldPosition );\r\n\r\n\t\tGizmo.Draw.LineThickness = Gizmo.IsSelected ? 3f : 2f;\r\n\t\tGizmo.Draw.Color = TracerColour.WithAlpha( alpha );\r\n\t\tGizmo.Draw.Line( sa, sb );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( sa, 8f ), 8 );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( sb, 8f ), 8 );\r\n\r\n\t\tGizmo.Draw.Color = Color.Red.WithAlpha( alpha );\r\n\t\tGizmo.Draw.Line( ea, eb );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( ea, 8f ), 8 );\r\n\t\tGizmo.Draw.LineSphere( new Sphere( eb, 8f ), 8 );\r\n\r\n\t\tGizmo.Draw.LineThickness = 1f;\r\n\t\tGizmo.Draw.Color = Color.White.WithAlpha( alpha * 0.7f );\r\n\t\tGizmo.Draw.Arrow( (sa + sb) * 0.5f, (ea + eb) * 0.5f, 24f, 8f );\r\n\t}\r\n}\r\n"
        },
        {
            "Ident": "mrtn.golfkit",
            "Path": "GolfMenuCamera.cs",
            "FileName": "GolfMenuCamera.cs",
            "PackageType": "library",
            "CodeKind": "Game",
            "AssetVersionId": 425925,
            "IsPrivate": false,
            "Code": "using Sandbox;\r\nusing System;\r\nusing System.Linq;\r\n\r\n/// <summary>\r\n/// Where the camera sits behind the main menu, the course settings and the lobby. Place one in a\r\n/// scene (the menu_camera prefab) and the fly-over's menu orbit is replaced by this view: the\r\n/// object's position and rotation with a gentle sway and a slight push-in. A child object named\r\n/// End makes it drift back and forth between the two points as well. Without one in the scene\r\n/// the old orbit round the start hole is used. Editor gizmo: the camera's frustum, and the path\r\n/// to End when there is one.\r\n/// </summary>\r\n[Title( \"Golf Menu Camera\" ), Icon( \"videocam\" ), Category( \"Golf\" )]\r\npublic sealed class GolfMenuCamera : Component\r\n{\r\n\t/// <summary>The menu camera in the loaded scene, or null.</summary>\r\n\tpublic static GolfMenuCamera Current { get; private set; }\r\n\r\n\t/// <summary>How far the sway moves the camera sideways and up, in units. 0 holds it still.</summary>\r\n\t[Property, Title( \"Drift Amount\" ), Range( 0f, 200f )]\r\n\tpublic float DriftAmount { get; set; } = 24f;\r\n\r\n\t/// <summary>Seconds for one full sway back and forth.</summary>\r\n\t[Property, Title( \"Drift Period\" ), Range( 2f, 60f )]\r\n\tpublic float DriftPeriod { get; set; } = 14f;\r\n\r\n\t/// <summary>How far the camera eases forward and back along its view, in units.</summary>\r\n\t[Property, Title( \"Push In\" ), Range( 0f, 400f )]\r\n\tpublic float PushIn { get; set; } = 40f;\r\n\r\n\t/// <summary>Degrees of slow look-around that go with the sway. 0 keeps the rotation fixed.</summary>\r\n\t[Property, Title( \"Sway Degrees\" ), Range( 0f, 10f )]\r\n\tpublic float SwayDegrees { get; set; } = 1.5f;\r\n\r\n\t/// <summary>Seconds for one trip from here to End and back, when a child named End exists.</summary>\r\n\t[Property, Title( \"Loop Seconds\" ), Range( 4f, 120f )]\r\n\tpublic float LoopSeconds { get; set; } = 30f;\r\n\r\n\t/// <summary>Vertical field of view of the menu view.</summary>\r\n\t[Property, Title( \"Field Of View\" ), Range( 30f, 110f )]\r\n\tpublic float FieldOfView { get; set; } = 70f;\r\n\r\n\tprotected override void OnEnabled()\r\n\t{\r\n\t\tCurrent = this;\r\n\t}\r\n\r\n\tprotected override void OnDisabled()\r\n\t{\r\n\t\tif ( Current == this ) Current = null;\r\n\t}\r\n\r\n\t/// <summary>The End child, if the camera loops between two points.</summary>\r\n\tGameObject End => GameObject.Children.FirstOrDefault( c => c.IsValid() && string.Equals( c.Name, \"End\", StringComparison.OrdinalIgnoreCase ) );\r\n\r\n\t/// <summary>The camera's position and rotation at a moment in time, drift and all.</summary>\r\n\tpublic Transform ViewAt( float time )\r\n\t{\r\n\t\tvar start = WorldTransform;\r\n\t\tvar position = start.Position;\r\n\t\tvar rotation = start.Rotation;\r\n\r\n\t\t// Back and forth between here and End, easing at both ends.\r\n\t\tvar end = End;\r\n\t\tif ( end.IsValid() && LoopSeconds > 0f )\r\n\t\t{\r\n\t\t\tvar phase = (time % LoopSeconds) / LoopSeconds;          // 0..1 over one round trip\r\n\t\t\tvar t = phase < 0.5f ? phase * 2f : 2f - phase * 2f;      // 0 -> 1 -> 0\r\n\t\t\tt = t * t * (3f - 2f * t);                                // smoothstep\r\n\t\t\tposition = Vector3.Lerp( start.Position, end.WorldPosition, t );\r\n\t\t\trotation = Rotation.Slerp( start.Rotation, end.WorldRotation, t );\r\n\t\t}\r\n\r\n\t\t// The sway: a slow figure of eight sideways and up, with a push-in on a longer beat.\r\n\t\tvar period = MathF.Max( 2f, DriftPeriod );\r\n\t\tvar w = MathF.Tau * time / period;\r\n\t\tvar sideways = MathF.Sin( w ) * DriftAmount;\r\n\t\tvar upward = MathF.Sin( w * 0.5f + 1.3f ) * DriftAmount * 0.5f;\r\n\t\tvar forward = (0.5f + 0.5f * MathF.Sin( w * 0.25f )) * PushIn;\r\n\r\n\t\tvar swayed = rotation * Rotation.From( MathF.Sin( w * 0.5f ) * SwayDegrees * 0.5f, MathF.Sin( w ) * SwayDegrees, 0f );\r\n\t\tposition += rotation.Right * sideways + rotation.Up * upward + rotation.Forward * forward;\r\n\r\n\t\treturn new Transform( position, swayed );\r\n\t}\r\n\r\n\tprotected override void DrawGizmos()\r\n\t{\r\n\t\tvar selected = Gizmo.IsSelected || Gizmo.IsHovered;\r\n\t\tvar colour = new Color( 0.37f, 0.83f, 0.42f );\r\n\t\tGizmo.Draw.LineThickness = selected ? 2f : 1f;\r\n\t\tGizmo.Draw.Color = colour.WithAlpha( selected ? 0.95f : 0.55f );\r\n\r\n\t\t// The frustum in local space: the near and far rectangles of the field of view, joined up.\r\n\t\tvar aspect = 16f / 9f;\r\n\t\tvar near = 20f;\r\n\t\tvar far = selected ? 600f : 300f;\r\n\t\tvar tan = MathF.Tan( MathX.DegreeToRadian( FieldOfView ) * 0.5f );\r\n\t\tVector3[] Corners( float d )\r\n\t\t{\r\n\t\t\tvar h = tan * d;\r\n\t\t\tvar w = h * aspect;\r\n\t\t\treturn new[]\r\n\t\t\t{\r\n\t\t\t\tnew Vector3( d, -w, -h ), new Vector3( d, w, -h ),\r\n\t\t\t\tnew Vector3( d, w, h ), new Vector3( d, -w, h ),\r\n\t\t\t};\r\n\t\t}\r\n\t\tvar n = Corners( near );\r\n\t\tvar f = Corners( far );\r\n\t\tfor ( var i = 0; i < 4; i++ )\r\n\t\t{\r\n\t\t\tGizmo.Draw.Line( n[i], n[(i + 1) % 4] );\r\n\t\t\tGizmo.Draw.Line( f[i], f[(i + 1) % 4] );\r\n\t\t\tGizmo.Draw.Line( Vector3.Zero, f[i] );\r\n\t\t}\r\n\r\n\t\t// A small body so it can be found and clicked.\r\n\t\tGizmo.Draw.LineBBox( new BBox( new Vector3( -14f, -8f, -8f ), new Vector3( 4f, 8f, 8f ) ) );\r\n\r\n\t\t// The loop path to End, in world space.\r\n\t\tvar end = End;\r\n\t\tif ( end.IsValid() )\r\n\t\t{\r\n\t\t\tvar local = WorldTransform.PointToLocal( end.WorldPosition );\r\n\t\t\tGizmo.Draw.Color = colour.WithAlpha( 0.8f );\r\n\t\t\tGizmo.Draw.Arrow( Vector3.Zero, local, 12f, 4f );\r\n\t\t}\r\n\t}\r\n}\r\n"
        }
    ]
}