A component that implements barbed wire behavior on a golf course. It detects player bodies and golf balls overlapping a trigger, slows players, schedules periodic scrapes that notify the course events, damages players through IGolfCourseEvents, drags balls toward a reduced speed, and plays a scrape sound via an RPC when requested.
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;
}
}