Weapons/ToolGun/Modes/SpawnerTool.cs

Tool mode for the ToolGun that previews and places spawned objects. It syncs a serialized ISpawner payload from host to clients, reconstructs local ISpawner instances, handles aiming, rotation/snapping, draws preview materials, and triggers spawn RPCs and spawn effects.

NetworkingFile Access
/// <summary>
/// Previews and places objects into the world.
/// Accepts any <see cref="ISpawner"/> to define what to spawn.
/// The spawn menu (or any other system) sets the payload, and this mode handles
/// aiming, previewing, and placement.
/// </summary>
[Hide]
[HiddenToolMode]
[Icon( "📦" )]
[Title( "#spawner.name" )]
[ClassName( "spawner" )]
[Group( "#tool.group.building" )]
public sealed class SpawnerTool : ToolMode
{
	/// <summary>
	/// Synced payload descriptor. When this changes on any client,
	/// <see cref="OnPayloadDataChanged"/> reconstructs the <see cref="ISpawner"/> locally.
	/// </summary>
	[Sync( SyncFlags.FromHost ), Change( nameof( OnPayloadDataChanged ) )]
	public string SpawnerData { get; set; }

	/// <summary>
	/// The local spawner, built from <see cref="SpawnerData"/>.
	/// </summary>
	public ISpawner Spawner { get; private set; }

	private Material _previewMaterial;
	private Material _previewMaterialInvalid;

	/// <summary>
	/// True while the player is holding reload to rotate the preview.
	/// </summary>
	private bool _isRotating;

	/// <summary>
	/// Accumulated rotation offset applied to the spawn preview.
	/// </summary>
	private Rotation _rotationOffset = Rotation.Identity;

	private Rotation _snapRotation = Rotation.Identity;
	private Rotation _spinRotation = Rotation.Identity;
	private bool _isSnapping;

	public override string Description => Spawner is null
		? "#spawner.name"
		: LocalizedText.Literal( Game.Language.GetPhrase( "spawner.placing", new() { { "name", LocalizedText.Resolve( Spawner.DisplayName ) } } ) );

	public override string PrimaryAction => Spawner is not null ? "#spawner.spawn" : null;
	public override string ReloadAction => Spawner is not null ? "#spawner.rotate" : null;

	/// <summary>
	/// Hold snap to steer the aim onto the grid, so placement lands on grid points.
	/// </summary>
	public override bool UseSnapGrid => Spawner is not null;

	public override void OnCameraMove( Player player, ref Angles angles )
	{
		base.OnCameraMove( player, ref angles );

		if ( _isRotating )
			angles = default;
	}

	protected override void OnStart()
	{
		base.OnStart();

		_previewMaterial = Material.Load( "materials/effects/duplicator_override.vmat" );
		_previewMaterialInvalid = Material.Load( "materials/effects/duplicator_override_other.vmat" );
	}

	/// <summary>
	/// Set what this mode should spawn. Host only, syncs to all clients via <see cref="SpawnerData"/>.
	/// </summary>
	public void SetSpawner( ISpawner payload )
	{
		Spawner = payload;
		SpawnerData = SerializeSpawner( payload );
	}

	/// <summary>
	/// Clear the current payload, returning to an idle state.
	/// </summary>
	[Rpc.Host]
	private void ClearPayload()
	{
		SpawnerData = null;
	}

	/// <summary>
	/// Called on every client when <see cref="SpawnerData"/> changes.
	/// Reconstructs the <see cref="ISpawner"/> locally so each client can render the preview.
	/// </summary>
	private void OnPayloadDataChanged()
	{
		Spawner = DeserializeSpawner( SpawnerData );

		// Only the player holding it sees the loading ring - never the host on someone else's behalf
		if ( Player?.IsLocalPlayer == true ) CloudLoadTracker.Track( Spawner?.Loading );
	}

	record SpawnerPayload( string Type, string Data, string Metadata );

	/// <summary>
	/// Serialize a spawner for networking, including its <see cref="ISpawner.Metadata"/>
	/// so clients build it the same as the host, even if they're missing its content.
	/// </summary>
	private static string SerializeSpawner( ISpawner spawner )
	{
		var type = spawner switch
		{
			PropSpawner => "prop",
			EntitySpawner => "entity",
			MountSpawner => "mount",
			DuplicatorSpawner => "dupe",
			_ => null
		};

		if ( type is null )
			return null;

		return Json.Serialize( new SpawnerPayload( type, spawner.Data, spawner.Metadata ) );
	}

	/// <summary>
	/// Reconstruct an <see cref="ISpawner"/> from <see cref="SerializeSpawner"/>
	/// </summary>
	private static ISpawner DeserializeSpawner( string data )
	{
		if ( string.IsNullOrWhiteSpace( data ) )
			return null;

		var payload = Json.Deserialize<SpawnerPayload>( data );
		if ( payload is null )
			return null;

		return ISpawner.Create( payload.Type, payload.Data, metadata: payload.Metadata );
	}

	/// <summary>
	/// Handles placing, clearing, and rotating the preview while reload is held.
	/// </summary>
	public override void OnControl()
	{
		base.OnControl();

		if ( Spawner is null )
		{
			// The base stops updating the grid once UseSnapGrid goes false, so don't leave it hanging
			SnapGrid?.Hide();
			IsValidState = false;
			return;
		}

		_isRotating = Input.Down( "reload" );
		Toolgun.SetIsUsingJoystick( _isRotating );

		var isSnapping = Input.Down( "run" );
		if ( !isSnapping && _isSnapping ) _spinRotation = _snapRotation;
		_isSnapping = isSnapping;

		if ( _isRotating )
		{
			var look = Input.AnalogLook with { pitch = 0 };

			if ( _isSnapping )
			{
				if ( MathF.Abs( look.yaw ) > MathF.Abs( look.pitch ) ) look.pitch = 0;
				else look.yaw = 0;
			}

			_spinRotation = Rotation.From( look ) * _spinRotation;
			Input.Clear( "reload" );

			if ( _isSnapping )
			{
				var snapped = _spinRotation.Angles();
				_rotationOffset = snapped.SnapToGrid( 45f );
			}
			else
			{
				_rotationOffset = _spinRotation;
			}

			_snapRotation = _rotationOffset;

			Toolgun.UpdateJoystick( new Angles( look.yaw, look.pitch, 0 ) );
		}

		var placement = GetPlacementInfo();
		IsValidState = placement.Hit && Spawner.IsReady;

		if ( IsValidState && Input.Pressed( "attack1" ) )
		{
			var transform = GetSpawnTransform( placement );
			DoSpawn( transform, new Transform( placement.EndPosition, Rotation.LookAt( placement.Normal ) ) );
			_rotationOffset = Rotation.Identity;
			_spinRotation = Rotation.Identity;
		}
	}

	protected override void OnDisabled()
	{
		base.OnDisabled();

		_isRotating = false;
		Toolgun?.SetIsUsingJoystick( false );
	}

	protected override void OnUpdate()
	{
		base.OnUpdate();

		if ( Application.IsDedicatedServer ) return;
		if ( Spawner is null ) return;
		if ( !Player.IsValid() ) return;

		// Draw preview on all clients, so everyone can see what's being placed
		DrawPreview();
	}

	private void DrawPreview()
	{
		var placement = GetPlacementInfo();
		if ( !placement.Hit ) return;

		var transform = GetSpawnTransform( placement );

		// Use a different material for other players' previews, same as the Duplicator
		var material = IsProxy
			? _previewMaterialInvalid
			: IsValidState ? _previewMaterial : _previewMaterialInvalid;

		Spawner.DrawPreview( transform, material );
	}

	private SceneTraceResult GetPlacementInfo()
	{
		return Scene.Trace.Ray( Player.EyeTransform.ForwardRay, 4096 )
			.IgnoreGameObjectHierarchy( Player.GameObject )
			.WithoutTags( "player" )
			.Run();
	}

	private Transform GetSpawnTransform( SceneTraceResult trace )
	{
		var up = trace.Normal;
		var backward = -Player.EyeTransform.Forward;
		var right = Vector3.Cross( up, backward ).Normal;
		var forward = Vector3.Cross( right, up ).Normal;
		var facingAngle = Rotation.LookAt( forward, up );

		var position = trace.EndPosition;

		// Offset by bounds so the object sits on the surface
		if ( Spawner is not null )
		{
			position += up * -Spawner.Bounds.Mins.z;
		}

		return new Transform( position, facingAngle * _rotationOffset );
	}

	[Rpc.Host]
	private async void DoSpawn( Transform transform, Transform effectTarget )
	{
		if ( Spawner is null ) return;

		var player = Player.FindForConnection( Rpc.Caller );
		if ( player is null ) return;

		var spawner = Spawner;

		var spawnData = new Global.ISpawnEvents.SpawnData
		{
			Spawner = spawner,
			Transform = transform,
			Player = player.Network.Owner
		};

		Scene.RunEvent<Global.ISpawnEvents>( x => x.OnSpawn( spawnData ) );

		if ( spawnData.Cancelled )
			return;

		var objects = await spawner.Spawn( transform, player );

		if ( objects is { Count: > 0 } )
		{
			if ( this.IsValid() ) SpawnEffects( effectTarget );

			var undo = player.Undo.Create();
			undo.Name = "#undo.spawn";
			undo.NameTokens = new() { { "name", spawner.DisplayName } };
			undo.Add( objects );

			Scene.RunEvent<Global.ISpawnEvents>( x => x.OnPostSpawn( new Global.ISpawnEvents.PostSpawnData
			{
				Spawner = spawner,
				Transform = transform,
				Player = player.Network.Owner,
				Objects = objects
			} ) );
		}
	}

	[Rpc.Broadcast( NetFlags.HostOnly )]
	private void SpawnEffects( Transform target ) => Toolgun?.PlayToolEffects( target );

	/// <summary>
	/// Draws the payload's icon on the viewmodel screen, falling back to the scrolling tool name.
	/// </summary>
	public override void DrawScreen( Rect rect, Painter paint )
	{
		var icon = Spawner?.Icon switch
		{
			null => null,
			var i when i.StartsWith( "http", StringComparison.OrdinalIgnoreCase ) => Texture.Load( i ),
			var i => Texture.Load( $"thumb:{i}" )
		};

		if ( icon is null )
		{
			base.DrawScreen( rect, paint );
			return;
		}

		var size = rect.Height;
		var iconRect = new Rect(
			rect.Center.x - size * 0.5f,
			rect.Center.y - size * 0.5f,
			size,
			size
		);
		paint.Texture( icon, iconRect );
	}
}