Weapons/PeachLauncher/PeachProjectile.cs
/// <summary>
/// A flying Moonlight Peach projectile.
///
/// Has a health pool — shoot it mid-air to detonate early.
/// On collision (health == 0) it spawns the splat prefab parented to the surface
/// it hit, so the decal sticks to moving props and the world alike.
/// </summary>
[Title( "Peach Projectile" )]
[Category( "Game / Peach" )]
public class PeachProjectile : Component, Component.IDamageable, Component.ICollisionListener
{
	// ── Prefab wiring ──────────────────────────────────────────────────────────
	[Property] public GameObject SplatPrefab      { get; set; }
	[Property] public GameObject GiantSplatPrefab { get; set; }
	[Property] public GameObject ImpactParticle   { get; set; }

	// ── Peach identity ────────────────────────────────────────────────────────
	[Property] public bool IsGiant { get; set; } = false;

	/// <summary>Hit points before the peach splats. 0 = always splats on first impact.</summary>
	[Property, Range( 0, 10 )] public float MaxHealth { get; set; } = 3f;

	/// <summary>Damage dealt to whatever the peach hits directly. Set to 100 to one-shot a player.</summary>
	[Property, Range( 0, 200 )] public float DirectHitDamage { get; set; } = 100f;

	// ── Networked state ───────────────────────────────────────────────────────
	[Sync] public float Health { get; set; }

	// ── Runtime ───────────────────────────────────────────────────────────────
	private bool _splatted = false;

	protected override void OnStart()
	{
		Health = MaxHealth;
	}

	// ── ICollisionListener ────────────────────────────────────────────────────

	public void OnCollisionStart( Collision collision )
	{
		if ( IsProxy || _splatted ) return;
		if ( Health > 0f ) return; // Still has HP — bounce naturally

		DoSplat( collision.Contact.Point, collision.Contact.Normal, collision.Other.GameObject );
	}

	public void OnCollisionUpdate( Collision collision ) { }
	public void OnCollisionStop( CollisionStop collision ) { }

	// ── IDamageable — shoot the peach in the air ─────────────────────────────

	public void OnDamage( in DamageInfo info )
	{
		if ( IsProxy || _splatted ) return;

		Health = (Health - info.Damage).Clamp( 0f, MaxHealth );

		if ( Health <= 0f )
		{
			var normal = (WorldPosition - info.Position).Normal;
			// Mid-air pops have no surface to stick to — splat freely
			DoSplat( info.Position, normal, null );
		}
	}

	// ── Splat ─────────────────────────────────────────────────────────────────

	void DoSplat( Vector3 position, Vector3 normal, GameObject surface )
	{
		if ( _splatted ) return;
		_splatted = true;

		// Deal direct hit damage before stamping the splat
		DealDirectDamage( surface );
		StampSplat( position, normal, surface );

		// Small visual-only impact at the hit location for feedback
		if ( ImpactParticle.IsValid() )
			ImpactParticle.Clone( new Transform( position + normal * 2f, Rotation.LookAt( normal ) ) );

		if ( !IsProxy )
			GameObject.Destroy();
	}

	/// <summary>
	/// Stamp the splat mesh directly at the impact point, aligned to the
	/// surface normal, parented to whatever was hit so it moves with the
	/// surface. Works for walls, floors, ceilings, players, moving props —
	/// anything with a collider that the peach can hit.
	///
	/// Mid-air pops (surface == null) drop the splat in world space at the
	/// pop location — it just hangs there until FadeAndDestroy cleans it up,
	/// which is fine because mid-air pops are visually punctuated by the
	/// impact particle anyway.
	/// </summary>
	/// <summary>
	/// If the surface we hit belongs to a player or damageable, deal direct hit damage.
	/// Called before StampSplat so the target is still alive when the splat is placed.
	/// </summary>
	private void DealDirectDamage( GameObject surface )
	{
		if ( !Networking.IsHost ) return;
		if ( !surface.IsValid() ) return;

		var damageable = surface.GetComponentInParent<Component.IDamageable>( true );
		if ( damageable is null ) return;

		// Don't damage the player who fired this peach
		var ownerPlayer = GetComponentInParent<Player>( true );
		if ( ownerPlayer.IsValid() && surface.GetComponentInParent<Player>( true ) == ownerPlayer ) return;

		var info = new DamageInfo( DirectHitDamage, GameObject, GameObject );
		info.Position = WorldPosition;
		damageable.OnDamage( info );

		// Notify SplatReceiver if present — covers players, turrets, anything damageable
		var receiver = surface.GetComponentInParent<SplatReceiver>( true );
		if ( receiver.IsValid() )
		{
			var splatInfo = new SplatInfo
			{
				Position    = WorldPosition,
				Normal      = -WorldRotation.Forward,
				Attacker    = GetComponentInParent<Player>( true ),
				IsDirectHit = true,
				IsGiant     = IsGiant,
			};
			receiver.ReceiveSplat( splatInfo );
		}
	}

	private void StampSplat( Vector3 position, Vector3 normal, GameObject surface )
	{
		if ( !Networking.IsHost ) return; // only host spawns networked splats

		var prefabToUse = (IsGiant && GiantSplatPrefab.IsValid()) ? GiantSplatPrefab : SplatPrefab;
		if ( !prefabToUse.IsValid() ) return;

		var spinAngle = Game.Random.Float( 0f, 360f );
		var splatRot  = Rotation.LookAt( normal, Vector3.Random ) * Rotation.FromAxis( Vector3.Up, spinAngle );
		var splatPos  = position + normal * 1f;
		float scale   = IsGiant ? 3.0f : 1.0f;

		var splatGo = prefabToUse.Clone( new Transform( splatPos, splatRot, scale ) );

		// Parent to static world surfaces before NetworkSpawn so the parent
		// is already set when clients receive the object.
		// Avoid parenting to players or networked moving objects — their
		// network identity can cause reparenting conflicts on clients.
		var isStaticSurface = surface.IsValid() && !surface.GetComponentInChildren<Rigidbody>( true ).IsValid();
		if ( isStaticSurface )
			splatGo.SetParent( surface, true );

		splatGo.NetworkSpawn();

		// For moving/physics surfaces, reparent after spawning
		if ( surface.IsValid() && !isStaticSurface )
			splatGo.SetParent( surface, true );

		var splatComp = splatGo.Components.Get<PeachSplat>( FindMode.EnabledInSelfAndDescendants );
		if ( splatComp.IsValid() )
			splatComp.IsGiant = IsGiant;

		// Notify SplatReceiver on the surface — handles non-damageable objects
		// (walls, crates, peach trees) that weren't caught by DealDirectDamage.
		if ( surface.IsValid() )
		{
			var receiver = surface.GetComponentInParent<SplatReceiver>( true );
			if ( receiver.IsValid() )
			{
				var splatInfo = new SplatInfo
				{
					Position    = position,
					Normal      = normal,
					Attacker    = GetComponentInParent<Player>( true ),
					IsDirectHit = false,
					IsGiant     = IsGiant,
				};
				receiver.ReceiveSplat( splatInfo );
			}
		}
	}
}