Weapons/PeachLauncher/PeachLauncherWeapon.cs
using Sandbox.Rendering;
/// <summary>
/// Peach Launcher — fires Moonlight Peach projectiles as a proper BaseWeapon
/// inventory item inside PlayerInventory's slot system.
///
/// ARCHITECTURE
/// ─────────────
/// Bone parenting, animgraph HoldType, and proxy visibility are all handled
/// by BaseCarryable.CreateWorldModel() — it clones WorldModelPrefab locally
/// on every client marked NotNetworked, so there is no transform sync conflict.
///
/// Projectile spawning is routed through [Rpc.Host] so the host always
/// performs the NetworkSpawn — see RpgWeapon for the same pattern.
///
/// PREFAB SETUP (peach_launcher.prefab)
/// ─────────────────────────────────────
/// Root GO components:
/// PeachLauncherWeapon ← this script
/// Rigidbody ← disabled while held via SetDropped()
/// ModelCollider ← same
/// DroppedWeapon ← press-to-pickup tooltip
///
/// Inspector — BaseCarryable section:
/// WorldModelPrefab → weapons/peach_launcher/peach_launcher_worldmodel.prefab
/// HoldType → Pistol
/// ParentBone → hold_r
///
/// Inspector — BaseWeapon / Ammo section:
/// UsesAmmo → true
/// UsesClips → false
/// MaxReserveAmmo → 5
/// StartingAmmo → 5
///
/// Inspector — this script:
/// PeachProjectilePrefab → your moonlightpeachprojectile.prefab
/// </summary>
[Title( "Peach Launcher Weapon" )]
[Category( "Game / Peach" )]
public class PeachLauncherWeapon : BaseWeapon
{
// ── Wiring ────────────────────────────────────────────────────────────────
[Property] public GameObject PeachProjectilePrefab { get; set; }
[Property] public GameObject GiantPeachProjectilePrefab { get; set; }
// ── Tuning ────────────────────────────────────────────────────────────────
[Property, Range( 100f, 5000f )] public float PeachSpeed { get; set; } = 1400f;
[Property, Range( 10f, 500f )] public float PeachMass { get; set; } = 60f;
[Property, Range( 0f, 10f )] public float PeachHealth { get; set; } = 3f;
[Property, Range( 0f, 20f )] public float Spread { get; set; } = 1.5f;
[Property, Range( 10f, 200f )] public float MuzzleOffset { get; set; } = 60f;
[Property, Range( 0.2f, 5f )] public float FireRate { get; set; } = 0.6f;
// Giant peach lob tuning
[Property, Range( 100f, 2000f )] public float GiantPeachSpeed { get; set; } = 600f;
[Property, Range( 10f, 500f )] public float GiantPeachMass { get; set; } = 200f;
[Property, Range( 0f, 10f )] public float GiantPeachHealth { get; set; } = 0f; // splats on first impact
[Property, Range( 0.5f, 5f )] public float GiantFireRate { get; set; } = 1.5f;
// ── BaseWeapon overrides ───────────────────────────────────────────────────
protected override float GetPrimaryFireRate() => FireRate;
// Single shot per click — not hold-to-spam
protected override bool WantsPrimaryAttack() => Input.Pressed( "attack1" );
// No iron sights / secondary mode
public override bool CanSecondaryAttack() => HasAmmo() && !IsReloading() && TimeUntilNextShotAllowed <= 0;
protected override bool WantsSecondaryAttack() => Input.Pressed( "attack2" );
public override void SecondaryAttack()
{
if ( !TakeAmmo( 1 ) ) return;
AddShootDelay( GiantFireRate );
WeaponModel?.OnAttack();
// Compute aim values locally on the firing client (so spread/origin use
// this player's camera) then RPC the host to actually spawn the peach.
var (spawnPos, forward) = GetProjectileOrigin();
var lobbed = (forward + Vector3.Up * 0.4f).Normal;
SpawnGiantPeachOnHost( spawnPos, lobbed );
}
public override void PrimaryAttack()
{
if ( !TakeAmmo( 1 ) ) return;
AddShootDelay( FireRate );
WeaponModel?.OnAttack();
// Compute spawn position and direction locally on the firing client so
// spread, muzzle position and camera direction reflect THIS player's
// view. Then RPC the host to perform the authoritative NetworkSpawn.
var (spawnPos, forward) = GetProjectileOrigin();
forward = (forward + Vector3.Random * (Spread * MathF.PI / 180f)).Normal;
SpawnPeachOnHost( spawnPos, forward );
}
// ── Host-authoritative spawning ──────────────────────────────────────────
//
// These follow the same pattern as RpgWeapon.CreateProjectile — the host
// performs the NetworkSpawn so the projectile exists on every client.
[Rpc.Host]
private void SpawnPeachOnHost( Vector3 spawnPos, Vector3 forward )
{
if ( !PeachProjectilePrefab.IsValid() )
{
Log.Warning( "PeachLauncherWeapon: PeachProjectilePrefab is not assigned!" );
return;
}
var peach = PeachProjectilePrefab.Clone( new Transform( spawnPos, Rotation.LookAt( forward ) ) );
peach.NetworkSpawn();
if ( peach.Components.Get<Rigidbody>( FindMode.EnabledInSelfAndDescendants ) is { } rb )
{
rb.MassOverride = PeachMass;
rb.Velocity = forward * PeachSpeed;
}
if ( peach.Components.Get<PeachProjectile>( FindMode.EnabledInSelfAndDescendants ) is { } proj )
{
proj.IsGiant = false;
proj.MaxHealth = PeachHealth;
}
}
[Rpc.Host]
private void SpawnGiantPeachOnHost( Vector3 spawnPos, Vector3 lobbed )
{
var prefab = GiantPeachProjectilePrefab.IsValid() ? GiantPeachProjectilePrefab : PeachProjectilePrefab;
if ( !prefab.IsValid() )
{
Log.Warning( "PeachLauncherWeapon: No giant peach prefab assigned!" );
return;
}
var peach = prefab.Clone( new Transform( spawnPos, Rotation.LookAt( lobbed ) ) );
peach.NetworkSpawn();
if ( peach.Components.Get<Rigidbody>( FindMode.EnabledInSelfAndDescendants ) is { } rb )
{
rb.MassOverride = GiantPeachMass;
rb.Velocity = lobbed * GiantPeachSpeed;
}
if ( peach.Components.Get<PeachProjectile>( FindMode.EnabledInSelfAndDescendants ) is { } proj )
{
proj.IsGiant = true;
proj.MaxHealth = GiantPeachHealth;
}
}
/// <summary>
/// Returns the correct projectile spawn position and aim direction for both
/// first and third person.
///
/// The problem: in third person AimRay.Position is the CAMERA position
/// (behind the player's head), not the muzzle. Spawning a Rigidbody there
/// puts it inside the player's own collider.
///
/// The solution: always spawn from the player's eye (or muzzle if available),
/// but aim in the direction of the camera ray so the crosshair is accurate.
/// Then nudge the spawn point forward enough to clear the player capsule.
/// </summary>
protected (Vector3 position, Vector3 forward) GetProjectileOrigin()
{
var forward = AimRay.Forward; // always correct direction
// Use the muzzle transform if available, otherwise the player's eye.
// Both are on the player, not the camera — safe for Rigidbody spawning.
Vector3 spawnPos;
if ( WeaponModel?.MuzzleTransform.IsValid() ?? false )
{
spawnPos = WeaponModel.MuzzleTransform.WorldPosition;
}
else if ( HasOwner )
{
spawnPos = Owner.EyeTransform.Position + forward * MuzzleOffset;
}
else
{
spawnPos = AimRay.Position + forward * MuzzleOffset;
}
return (spawnPos, forward);
}
// ── Crosshair ─────────────────────────────────────────────────────────────
public override void DrawCrosshair( HudPainter hud, Vector2 center )
{
var canShoot = HasAmmo() && !IsReloading() && TimeUntilNextShotAllowed <= 0;
var color = canShoot ? CrosshairCanShoot : CrosshairNoShoot;
hud.SetBlendMode( BlendMode.Normal );
hud.DrawCircle( center, 6, Color.Black );
hud.DrawCircle( center, 4, color );
}
// ── Dry fire ──────────────────────────────────────────────────────────────
public override void OnControl( Player player )
{
base.OnControl( player );
if ( Input.Pressed( "attack1" ) && !HasAmmo() )
DryFire();
}
}