SboxSuperuser/Engine/Bodies/PlayerControllerActions.cs

The default IPlayerActions implementation for games built on the engine's PlayerController. Runs the admin actions (kill, slap, freeze, blind, ignite, cloak, ragdoll, noclip, jail, strip, spectate, teleport to a player or to what they are aiming at, return, sound, map change); damage and slay go through the root's IDamageable components, and health, armor and god mode are left as overridable Unsupported stubs for games to replace in a subclass.

using Sandbox;
using SboxSuperuser.Engine.Bodies;

namespace SboxSuperuser;

/// <summary>
/// Player actions for games that use the engine's PlayerController. Damage, slay and ignite go through the engine's
/// IDamageable, so they work with any health component that implements it. Health, armor and god mode are the
/// game's own; override them, and anything else the game does differently, in a subclass passed to
/// Superuser.ConfigurePlayerActions.
/// </summary>
public class PlayerControllerActions : IPlayerActions
{
	private const float SlapSpeed = 400;
	private const float SlayDamage = 1_000_000_000f;
	private const float AimDistance = 4096;
	private const float BodyClearance = 32;
	private const float StandingDistance = 64;

	private readonly Dictionary<string, Vector3> _previousPositions = new();
	private readonly Dictionary<string, GameObject> _cages = new();
	private readonly Dictionary<string, GameObject> _fires = new();

	public virtual CommandResult Kill( string steamId, bool silent ) => Damage( steamId, SlayDamage, "superuser.slay" );

	public virtual CommandResult Slap( string steamId, float damage )
	{
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		var push = Vector3.Random.WithZ( 0 ).Normal * SlapSpeed + Vector3.Up * SlapSpeed;
		Superuser.ApplyBodyEffect( controller.Id, BodyEffect.Push, push );
		return damage > 0 ? Damage( steamId, damage, "superuser.slap" ) : Done();
	}

	public virtual CommandResult SetFrozen( string steamId, bool frozen ) =>
		Effect( steamId, frozen ? BodyEffect.Freeze : BodyEffect.Unfreeze );

	public virtual CommandResult SetBlind( string steamId, bool blind )
	{
		if ( PlayerBodies.Connection( steamId ) is not { } connection ) return new CommandResult( CommandStatus.NotFound, "That player is not connected." );
		using ( Rpc.FilterInclude( connection ) ) Superuser.SetBlindScreen( blind );
		return Done();
	}

	public virtual CommandResult SetIgnited( string steamId, bool ignited )
	{
		if ( _fires.Remove( steamId, out var fire ) && fire.IsValid() ) fire.Destroy();
		if ( !ignited ) return Done();
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		if ( !HasDamageable( controller ) ) return NoDamage();
		fire = controller.Scene.CreateObject();
		fire.Name = "Superuser Fire";
		fire.AddComponent<Burning>().Target = controller.GameObject;
		_fires[ steamId ] = fire;
		return Done();
	}

	public virtual CommandResult SetCloaked( string steamId, bool cloaked )
	{
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		Superuser.SetCloak( controller.Id, cloaked );
		return Done();
	}

	public virtual CommandResult SetGod( string steamId, bool god ) => Unsupported( "god mode" );

	public virtual CommandResult SetHealth( string steamId, int health ) => Unsupported( "health" );

	public virtual CommandResult SetArmor( string steamId, int armor ) => Unsupported( "armor" );

	public virtual CommandResult Strip( string steamId )
	{
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		var inventory = controller.GameObject.Root.GetComponentInChildren<BaseInventoryComponent>( true );
		if ( inventory is null ) return Unsupported( "an inventory" );
		foreach ( var item in inventory.GameObject.Root.GetComponentsInChildren<BaseInventoryItem>( true ).ToArray() ) inventory.Remove( item );
		return Done();
	}

	public virtual CommandResult SetRagdoll( string steamId, bool ragdoll ) =>
		Effect( steamId, ragdoll ? BodyEffect.Ragdoll : BodyEffect.Unragdoll, toEveryone: true );

	public virtual CommandResult SetNoclip( string steamId, bool noclip ) =>
		Effect( steamId, noclip ? BodyEffect.Noclip : BodyEffect.Unnoclip );

	public virtual CommandResult Spectate( string spectatorSteamId, string targetSteamId )
	{
		if ( PlayerBodies.Connection( spectatorSteamId ) is not { } spectator ) return new CommandResult( CommandStatus.NotFound, "You are not connected." );
		var target = Guid.Empty;
		if ( targetSteamId is not null )
		{
			if ( !PlayerBodies.TryFind( targetSteamId, out var controller, out var missing ) ) return missing;
			target = controller.Id;
		}
		using ( Rpc.FilterInclude( spectator ) ) Superuser.SpectateBody( target );
		return Done();
	}

	public virtual CommandResult MoveTo( string steamId, string destinationSteamId )
	{
		if ( !PlayerBodies.TryFind( destinationSteamId, out var destination, out var missing ) ) return missing;
		var facing = destination.EyeAngles.WithPitch( 0 ).Forward;
		return Teleport( steamId, destination.WorldPosition + facing * StandingDistance );
	}

	public virtual CommandResult MoveToAim( string steamId, string aimingSteamId )
	{
		if ( !PlayerBodies.TryFind( aimingSteamId, out var aiming, out var missing ) ) return missing;
		var start = aiming.EyePosition;
		var trace = aiming.Scene.Trace.Ray( start, start + aiming.EyeAngles.Forward * AimDistance )
			.IgnoreGameObjectHierarchy( aiming.GameObject.Root ).Run();
		if ( !trace.Hit ) return new CommandResult( CommandStatus.InvalidArguments, "Look at a surface to send them there." );
		// Step back from whatever was hit so the body doesn't end up inside it.
		return Teleport( steamId, trace.EndPosition + trace.Normal * BodyClearance );
	}

	public virtual CommandResult Return( string steamId )
	{
		if ( !_previousPositions.Remove( steamId, out var previous ) )
			return new CommandResult( CommandStatus.NoChange, "That player hasn't been moved since they spawned." );
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		Superuser.ApplyBodyEffect( controller.Id, BodyEffect.Teleport, previous );
		return Done();
	}

	public virtual CommandResult SetJailed( string steamId, bool jailed )
	{
		if ( _cages.Remove( steamId, out var cage ) && cage.IsValid() ) cage.Destroy();
		if ( !jailed ) return Done();
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		_cages[ steamId ] = JailCage.Build( controller.Scene, controller.WorldPosition, controller.BodyRadius, controller.BodyHeight );
		return Done();
	}

	public virtual CommandResult PlaySound( string sound )
	{
		Superuser.PlaySoundForEveryone( sound );
		return Done();
	}

	public virtual CommandResult ChangeMap( string map )
	{
		if ( Game.ActiveScene?.GetAllComponents<MapInstance>().Any() == true )
		{
			Superuser.ChangeMapForEveryone( map );
			return Done();
		}
		if ( Game.IsEditor ) return new CommandResult( CommandStatus.Unsupported, "This scene has no Map Instance to change; in the editor, stop play and change the scene." );
		LaunchArguments.Map = map;
		Game.Load( Game.Ident, true );
		return Done();
	}

	/// <summary>Moves a body and remembers where it was, for return.</summary>
	protected CommandResult Teleport( string steamId, Vector3 position )
	{
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		_previousPositions[ steamId ] = controller.WorldPosition;
		Superuser.ApplyBodyEffect( controller.Id, BodyEffect.Teleport, position );
		return Done();
	}

	/// <summary>Deals damage to every IDamageable on the player's root, tagged so games can tell it came from Superuser.</summary>
	protected CommandResult Damage( string steamId, float amount, string tag )
	{
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		if ( !HasDamageable( controller ) ) return NoDamage();
		var damage = new DamageInfo { Damage = amount, Position = controller.WorldPosition, Origin = controller.WorldPosition };
		damage.Tags.Add( tag );
		controller.GameObject.Root.RunEvent<Component.IDamageable>( listener => listener.OnDamage( damage ) );
		return Done();
	}

	private static CommandResult Effect( string steamId, BodyEffect effect, bool toEveryone = false )
	{
		if ( !PlayerBodies.TryFind( steamId, out var controller, out var missing ) ) return missing;
		if ( toEveryone ) Superuser.ApplyBodyEffect( controller.Id, effect, Vector3.Zero );
		else using ( Rpc.FilterInclude( controller.Network.Owner ) ) Superuser.ApplyBodyEffect( controller.Id, effect, Vector3.Zero );
		return Done();
	}

	private static bool HasDamageable( PlayerController controller ) =>
		controller.GameObject.Root.GetComponentsInChildren<Component.IDamageable>( true ).Any();

	private static CommandResult NoDamage() => new( CommandStatus.Unsupported,
		"This game's players don't take damage through the engine's IDamageable; override this action for your health system." );

	protected static CommandResult Unsupported( string what ) => new( CommandStatus.Unsupported,
		$"This game hasn't told Superuser about {what}; derive from PlayerControllerActions and pass it to Superuser.ConfigurePlayerActions." );

	protected static CommandResult Done() => new( CommandStatus.Success, "Done." );
}