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