GhostSpectator.cs

Ghost spectator ability component. While the local player is dead or spectating, it lets them place Spirit Beacons (use key) and Ghost Jump Pads (reload key), spawns a PointLight or trigger JumpPad at the traced aim position, and tracks cooldowns and per-round limits. Beacons expire after 35s, jump pads after 30s.

using Sandbox;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;

public sealed class GhostSpectator : Component
{
	public static GhostSpectator LocalInstance { get; private set; }
	public static List<SpiritBeaconData> ActiveBeacons { get; } = new();

	public struct SpiritBeaconData
	{
		public Vector3 Position;
		public string Label;
		public float RemainingTime;
	}

	[Property] public int MaxBeacons { get; set; } = 2;
	[Property] public int MaxJumpPads { get; set; } = 2;

	[Sync] public int BeaconsPlaced { get; private set; } = 0;
	[Sync] public int JumpPadsPlaced { get; private set; } = 0;

	public int BeaconsRemaining => Math.Max( 0, MaxBeacons - BeaconsPlaced );
	public int JumpPadsRemaining => Math.Max( 0, MaxJumpPads - JumpPadsPlaced );

	public float BeaconCooldown => 5f;
	public float JumpPadCooldown => 8f;

	[Sync] private TimeSince _timeSinceBeacon { get; set; } = 100f;
	[Sync] private TimeSince _timeSinceJumpPad { get; set; } = 100f;

	public bool CanPlaceBeacon => BeaconsRemaining > 0 && _timeSinceBeacon >= BeaconCooldown;
	public bool CanPlaceJumpPad => JumpPadsRemaining > 0 && _timeSinceJumpPad >= JumpPadCooldown;

	private readonly List<GameObject> _spawnedGhostObjects = new();

	protected override void OnStart()
	{
		if ( !IsProxy )
		{
			LocalInstance = this;
		}
	}

	public void ResetRoundLimits()
	{
		BeaconsPlaced = 0;
		JumpPadsPlaced = 0;
		_timeSinceBeacon = 100f;
		_timeSinceJumpPad = 100f;
		ClearGhostObjects();
	}

	protected override void OnUpdate()
	{
		if ( IsProxy ) return;

		bool isDead = PlayerHealth.LocalInstance.IsValid() && PlayerHealth.LocalInstance.IsDead;
		bool isSpectating = EvadeRoundManager.Instance.IsValid() && EvadeRoundManager.Instance.IsSpectating;

		if ( !isDead && !isSpectating ) return;

		// 1. PLACE SPIRIT BEACON (Key E)
		if ( Input.Pressed( "use" ) && CanPlaceBeacon )
		{
			TryPlaceBeacon();
		}

		// 2. PLACE GHOST JUMP PAD (Key R)
		if ( Input.Pressed( "reload" ) && CanPlaceJumpPad )
		{
			TryPlaceJumpPad();
		}

		// Update active beacons lifetime
		for ( int i = ActiveBeacons.Count - 1; i >= 0; i-- )
		{
			var b = ActiveBeacons[i];
			b.RemainingTime -= Time.Delta;
			if ( b.RemainingTime <= 0f )
			{
				ActiveBeacons.RemoveAt( i );
			}
			else
			{
				ActiveBeacons[i] = b;
			}
		}
	}

	public void TryPlaceBeacon()
	{
		if ( !CanPlaceBeacon ) return;

		var ray = Scene.Camera.IsValid()
			? new Ray( Scene.Camera.WorldPosition, Scene.Camera.WorldRotation.Forward )
			: new Ray( WorldPosition + Vector3.Up * 64f, WorldRotation.Forward );

		var tr = Scene.Trace.Ray( ray, 1000f )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		Vector3 placePos = tr.Hit ? tr.EndPosition : ray.Position + ray.Forward * 350f;

		BeaconsPlaced++;
		_timeSinceBeacon = 0f;

		SpawnSpiritBeacon( placePos );
		Sound.Play( "music/buttonhover.sound", placePos );
		Log.Info( $"[GhostSpectator] Spirit Beacon placed ({BeaconsRemaining} left)" );
	}

	public void TryPlaceJumpPad()
	{
		if ( !CanPlaceJumpPad ) return;

		var ray = Scene.Camera.IsValid()
			? new Ray( Scene.Camera.WorldPosition, Scene.Camera.WorldRotation.Forward )
			: new Ray( WorldPosition + Vector3.Up * 64f, WorldRotation.Forward );

		var tr = Scene.Trace.Ray( ray, 1000f )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		Vector3 placePos = tr.Hit ? tr.EndPosition : ray.Position + ray.Forward * 350f;

		JumpPadsPlaced++;
		_timeSinceJumpPad = 0f;

		SpawnGhostJumpPad( placePos );
		Sound.Play( "music/buttonhover.sound", placePos );
		Log.Info( $"[GhostSpectator] Ghost Jump Pad placed ({JumpPadsRemaining} left)" );
	}

	private void SpawnSpiritBeacon( Vector3 pos )
	{
		var beaconObj = Scene.CreateObject();
		beaconObj.Name = "Ghost_SpiritBeacon";
		beaconObj.WorldPosition = pos;

		var light = beaconObj.Components.Create<PointLight>();
		light.Radius = 350f;
		light.LightColor = Color.FromRgb( 0x38bdf8 ); // Cyan glowing beacon
		light.Shadows = false;

		ActiveBeacons.Add( new SpiritBeaconData
		{
			Position = pos,
			Label = "SPIRIT BEACON",
			RemainingTime = 35f
		} );

		_spawnedGhostObjects.Add( beaconObj );
		_ = DestroyDelayed( beaconObj, 35f );
	}

	private void SpawnGhostJumpPad( Vector3 pos )
	{
		var padObj = Scene.CreateObject();
		padObj.Name = "Ghost_JumpPad";
		padObj.WorldPosition = pos;

		var mr = padObj.Components.Create<ModelRenderer>();
		mr.Model = Model.Load( "models/sbox_props/metal_box/metal_box.vmdl" ) ?? Model.Load( "models/dev/box.vmdl" );
		mr.Tint = Color.FromRgb( 0x818cf8 ); // Ethereal indigo/violet
		padObj.WorldScale = new Vector3( 1.4f, 1.4f, 0.25f );

		var col = padObj.Components.Create<BoxCollider>();
		col.Scale = new Vector3( 60f, 60f, 25f );
		col.IsTrigger = true;

		var pad = padObj.Components.Create<JumpPad>();
		pad.LaunchForce = 1100f;
		pad.IsGhostPlaced = true;

		var light = padObj.Components.Create<PointLight>();
		light.Radius = 240f;
		light.LightColor = Color.FromRgb( 0xa855f7 );
		light.Shadows = false;

		_spawnedGhostObjects.Add( padObj );
		_ = DestroyDelayed( padObj, 30f );
	}

	private async Task DestroyDelayed( GameObject go, float delay )
	{
		await Task.DelaySeconds( delay );
		if ( go.IsValid() ) go.Destroy();
	}

	private void ClearGhostObjects()
	{
		foreach ( var obj in _spawnedGhostObjects )
		{
			if ( obj.IsValid() ) obj.Destroy();
		}
		_spawnedGhostObjects.Clear();
		ActiveBeacons.Clear();
	}

	protected override void OnDestroy()
	{
		ClearGhostObjects();
		if ( LocalInstance == this ) LocalInstance = null;
		base.OnDestroy();
	}
}