s&box Package Code Search
Search C# source code, UI razor templates, shaders, and configs across s&box packages.
link Raw Facepunch API Link: https://public.facepunch.com/sbox/code/search/1/?ident=mrtn.golfkit&take=20
Showing code results for query:
*
(40 total matches found)
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;
}
}
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 );
}
}
}
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;
}
}
}
}
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 );
}
}
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 );
}
}
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" );
}
}
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;
}
}
}
}
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();
}
}
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();
}
}
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")]
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 );
}
}
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 );
}
}
}
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;
}
}
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();
}
}
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();
}
}
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;
}
}
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 );
}
}
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;
}
}
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 );
}
}
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"
}
]
}