Spawner/SpawnerWeapon.cs
using Sandbox.Rendering;

/// <summary>
/// A weapon that 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 weapon handles
/// aiming, previewing, and placement.
/// </summary>
public partial class SpawnerWeapon : ScreenWeapon, IToolInfo
{
	/// <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; }

	/// <summary>
	/// Override the inventory icon with the payload's cloud thumbnail.
	/// </summary>
	public override string InventoryIconOverride => Spawner?.Icon switch
	{
		null => null,
		var icon when icon.StartsWith( "http", StringComparison.OrdinalIgnoreCase ) => icon,
		var icon => $"thumb:{icon}"
	};

	/// <summary>
	/// Whether the current aim position is a valid placement target.
	/// </summary>
	private bool _isValidPlacement;

	private Material _previewMaterial;
	private Material _previewMaterialInvalid;

	/// <summary>
	/// True while the player is holding Use 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 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 spawner should spawn. Serializes the payload and syncs to all clients via <see cref="SpawnerData"/>.
	/// </summary>
	public void SetSpawner( ISpawner payload )
	{
		Spawner = payload;
		SyncPayload( SerializeSpawner( payload ) );
	}

	/// <summary>
	/// Clear the current payload, returning to an idle state.
	/// </summary>
	public void ClearPayload()
	{
		SetSpawner( null );
	}

	/// <summary>
	/// Directly restores a previously serialized payload string
	/// </summary>
	public void RestoreSpawnerData( string serialisedData )
	{
		SyncPayload( serialisedData );
	}

	[Rpc.Host]
	private void SyncPayload( string data )
	{
		SpawnerData = data;
	}

	/// <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 );
	}
	/// <summary>
	/// Serialize a spawner for networking to <c>type:data</c>
	/// </summary>
	private static string SerializeSpawner( ISpawner spawner ) => spawner switch
	{
		PropSpawner => $"prop:{spawner.Data}",
		EntitySpawner => $"entity:{spawner.Data}",
		DuplicatorSpawner => $"dupe:{spawner.Data}",
		_ => null
	};

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

		var colonIndex = data.IndexOf( ':' );
		if ( colonIndex < 0 )
			return null;

		var type = data[..colonIndex];
		var value = data[(colonIndex + 1)..];

		return type switch
		{
			"prop" => new PropSpawner( value ),
			"entity" => new EntitySpawner( value ),
			"dupe" => DuplicatorSpawner.FromData( value ),
			_ => null
		};
	}

	public override void OnControl( Player player )
	{
		base.OnControl( player );

		UpdateViewmodelScreen();
		ApplyCoilSpin();


		if ( Spawner is null )
			return;

		_isRotating = Input.Down( "use" );
		SetIsUsingJoystick( _isRotating );

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

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

			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( "use" );

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

			_snapRotation = _rotationOffset;

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


		var placement = GetPlacementInfo( player );
		_isValidPlacement = placement.Hit;

		if ( _isValidPlacement && Spawner.IsReady && Input.Pressed( "attack1" ) )
		{
			var transform = GetSpawnTransform( placement, player );
			DoSpawn( transform );
			_rotationOffset = Rotation.Identity;
		}

		if ( Input.Pressed( "attack2" ) )
		{
			RemoveFromInventory();
		}
	}

	/// <summary>
	/// Remove this weapon from the player's inventory entirely.
	/// Holsters first, then destroys the game object.
	/// </summary>
	[Rpc.Host]
	private void RemoveFromInventory()
	{
		var inventory = Owner?.GetComponent<PlayerInventory>();
		if ( !inventory.IsValid() ) return;

		inventory.SwitchWeapon( null );
		DestroyGameObject();
	}

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

		if ( Spawner is null ) return;
		if ( !Owner.IsValid() ) return;

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

	private void DrawPreview()
	{
		var player = Owner;

		var placement = GetPlacementInfo( player );
		if ( !placement.Hit ) return;

		var transform = GetSpawnTransform( placement, player );

		// Use a different material for other players' previews, same as the Duplicator
		var material = IsProxy
			? _previewMaterialInvalid
			: (_isValidPlacement && Spawner.IsReady) ? _previewMaterial : _previewMaterialInvalid;

		Spawner.DrawPreview( transform, material );
	}

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

	private Transform GetSpawnTransform( SceneTraceResult trace, Player player )
	{
		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 )
	{
		if ( Spawner is null ) return;

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

		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 } )
		{
			var undo = player.Undo.Create();
			undo.Name = $"Spawn {Spawner.DisplayName}";

			foreach ( var go in objects )
			{
				undo.Add( go );
			}

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

	public override void DrawHud( HudPainter painter, Vector2 crosshair )
	{
		if ( Spawner is null )
		{
			// Idle crosshair
			painter.SetBlendMode( BlendMode.Normal );
			painter.DrawCircle( crosshair, 3, Color.White.WithAlpha( 0.3f ) );
			return;
		}

		var color = (_isValidPlacement && Spawner.IsReady) ? Color.White : new Color( 0.9f, 0.3f, 0.2f );

		painter.SetBlendMode( BlendMode.Normal );
		painter.DrawCircle( crosshair, 5, color.Darken( 0.3f ) );
		painter.DrawCircle( crosshair, 3, color );
	}

	protected override void DrawScreenContent( Rect rect, HudPainter paint )
	{
		var icon = Texture.Load( this.InventoryIconOverride );
		if ( icon is not null )
		{
			var size = rect.Height;
			var iconRect = new Rect(
				rect.Center.x - size * 0.5f,
				rect.Center.y - size * 0.5f,
				size,
				size
			);
			paint.DrawTexture( icon, iconRect );
		}
	}


	string IToolInfo.Name => "Spawner";
	string IToolInfo.Description => $"Placing {Spawner.DisplayName}";
	string IToolInfo.PrimaryAction => "Spawn";
	string IToolInfo.SecondaryAction => "Clear";
}