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();
}
}