Player/PlayerPawn.cs
using Sandbox.UI.Player;
using Sandbox.UI.ShipSelect;
/// <summary>
/// Main player component for FP4. Represents a controllable flight ship.
/// Attach to a GameObject that also has a SkinnedModelRenderer and Rigidbody.
/// </summary>
[Title( "Player Pawn" ), Icon( "flight" )]
public sealed partial class PlayerPawn : Component, Component.INetworkListener
{
// -------------------------------------------------------------------------
// Sub-components (set up in Respawn)
// -------------------------------------------------------------------------
[Property] public SkinnedModelRenderer ModelRenderer { get; set; }
[Property] public Rigidbody Rigidbody { get; set; }
// -------------------------------------------------------------------------
// Ship Data
// -------------------------------------------------------------------------
[Sync( SyncFlags.FromHost )] public ShipData Data { get; set; } = ResourceLibrary.Get<ShipData>( "ships/defaultship.ship" );
// -------------------------------------------------------------------------
// State
// -------------------------------------------------------------------------
// Host-authoritative state — set by Respawn(), TakeDamage(), OnKilled()
[Sync( SyncFlags.FromHost )] public bool IsAlive { get; set; } = false;
[Sync( SyncFlags.FromHost )] public bool HasSelectedShip { get; set; } = false;
[Sync( SyncFlags.FromHost )] public bool IsBot { get; set; } = false;
[Sync( SyncFlags.FromHost )] public string BotName { get; set; } = "";
// Bot AI input — set by BotController on host, read by ShootComponent/FlightController
[Sync( SyncFlags.FromHost )] public bool BotFirePrimary { get; set; } = false;
[Sync( SyncFlags.FromHost )] public bool BotWantsBoost { get; set; } = false;
[Sync( SyncFlags.FromHost )] public bool BotWantsBrake { get; set; } = false;
[Sync( SyncFlags.FromHost )] public float Health { get; set; }
[Sync( SyncFlags.FromHost )] public float MaxHealth { get; set; }
[Sync( SyncFlags.FromHost )] public float Shield { get; set; }
[Sync( SyncFlags.FromHost )] public float MaxShield { get; set; }
[Sync( SyncFlags.FromHost )] public int Kills { get; set; }
[Sync( SyncFlags.FromHost )] public int Deaths { get; set; }
[Sync( SyncFlags.FromHost )] public int Score { get; set; }
[Sync( SyncFlags.FromHost )] public Color TrailColor { get; set; }
[Property] TrailRenderer TrailRenderer { get; set; }
// -------------------------------------------------------------------------
// Flight Stats — set by host in Respawn(), mutated by owner in FlightController
// -------------------------------------------------------------------------
[Sync] public float Speed { get; set; }
[Sync] public float BoostSpeed { get; set; } = 40f;
[Sync] public float BoostCoolDown { get; set; } = 100f;
[Sync] public float BoostAmount { get; set; } = 100f;
[Sync( SyncFlags.FromHost )] public float BoostRegenRate { get; set; } = 100f;
[Sync] public float MinSpeed { get; set; } = 10f;
[Sync] public float MaxSpeed { get; set; } = 20f;
[Sync( SyncFlags.FromHost )] public float IdleSpeed { get; set; } = 20f;
[Sync( SyncFlags.FromHost )] public float CappedMaxSpeed { get; set; }
[Sync] public float Lean { get; set; }
[Sync] public float BreakLean { get; set; }
// -------------------------------------------------------------------------
// Input (replicated from owner to all — owner writes, host reads)
// -------------------------------------------------------------------------
[Sync] public Vector3 InputDirection { get; set; }
[Sync] public Angles ViewAngles { get; set; }
// -------------------------------------------------------------------------
// Internal state
// -------------------------------------------------------------------------
private TimeSince _timeSinceLastDamage = 10f;
public float TimeSinceLastDamage => (float)_timeSinceLastDamage;
private string _primaryWeapon;
private string _secondaryWeapon;
private ShipData _pendingShipData;
private bool _wasAlive = false;
private bool _pendingRespawn = false;
private TimeUntil _respawnTime;
private float _boostStatAccumulator;
private int _sessionInstagibKills;
private int _sessionShieldBreaks;
private float _sessionHullDamage;
private float _sessionBoostSeconds;
private int _sessionMatchesCompleted;
private readonly HashSet<string> _sessionWonShips = new();
private readonly Dictionary<string, int> _sessionShipWinCounts = new();
// ── Death screen timer (client-side) ──────────────────────────────────────
private bool _deathScreenActive = false;
private TimeUntil _deathScreenHideTime;
// Death camera — set when killed, cleared on respawn
[Sync] public PlayerPawn DeathCamTarget { get; set; }
[Sync] public bool HitWall { get; set; }
[Sync] public Vector3 WallHitNormal { get; set; }
// Ship prefab instance (model + trail + all visuals)
private GameObject _shipGo;
// Wall scrape spark effect (one persistent instance, toggled on/off)
private GameObject _wallSparkGo;
// Sounds
private SoundHandle _engineSound;
private SoundHandle _boostLoopSound;
private SoundHandle _wallGrindSound;
// Screen shake
public ScreenShake.Charge ChargeEffect { get; set; }
// -------------------------------------------------------------------------
// Lifecycle
// -------------------------------------------------------------------------
/// <summary>The local client's own pawn. Null if not yet spawned.</summary>
public static PlayerPawn Local { get; private set; }
protected override void OnStart()
{
// Use Connection.Local — more reliable than !IsProxy which may not be set yet on network receive
if ( !IsBot && Network.Owner == Connection.Local )
{
Local = this;
FlightCamera = new FlightCamera();
}
TrailColor = Color.Random;
if(TrailRenderer.IsValid())
TrailRenderer.Color = TrailColor;
}
protected override void OnDestroy()
{
if ( Local == this ) Local = null;
ChargeEffect?.Destroy();
ChargeEffect = null;
DestroyShip();
ProjectileSimulator?.Clear();
}
protected override void OnUpdate()
{
// Keep Local in sync in case OnStart fired before ownership was confirmed
if ( !IsBot && Network.Owner == Connection.Local && Local != this )
{
Local = this;
FlightCamera ??= new FlightCamera();
}
// Detect alive transition — run client-side setup when the host respawns us
if ( IsAlive && !_wasAlive )
{
_wasAlive = true;
OnBecameAlive();
}
else if ( !IsAlive && _wasAlive )
{
_wasAlive = false;
DestroyShip();
StopAllSounds(); // always stop — proxy/bot pawns have nearby sounds that must be cleaned up
}
// Camera runs even while dead (death cam looks at killer)
if ( !IsProxy && !IsBot )
{
FlightCamera?.Update( this );
}
if ( !IsAlive ) return;
// Sounds for ships near the local player — runs for ALL pawns (proxy, bot, self)
UpdateNearbySounds();
if ( IsAlive )
{
float shieldRatio = MaxShield > 0f ? Shield / MaxShield : 0f;
float shieldFade = Shield < MaxShield ? shieldRatio : 0f;
foreach ( var r in GameObject.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
r.SceneObject?.Attributes.Set( "damagefade", shieldFade );
}
if ( IsProxy || IsBot ) return;
// Death screen hide timer
if ( _deathScreenActive && _deathScreenHideTime )
{
_deathScreenActive = false;
ShipListHint.Current?.HideAttacker();
}
// Read input and update sounds
BuildInput();
// Update sounds every render frame so position tracks smoothly
UpdateSounds( HitWall );
// Toggle wall-scrape sparks and place them on the wall surface
if ( _wallSparkGo != null )
{
var hitting = HitWall && IsAlive;
_wallSparkGo.Enabled = hitting;
if ( hitting && WallHitNormal != Vector3.Zero )
{
// Trace from the ship toward the wall to find the exact contact point
var wallTrace = Scene.Trace.Ray( WorldPosition, WorldPosition - WallHitNormal * 64f )
.Size( 4f )
.IgnoreGameObject( GameObject )
.Run();
var contactPoint = wallTrace.Hit ? wallTrace.EndPosition : WorldPosition - WallHitNormal * 32f;
_wallSparkGo.WorldPosition = contactPoint;
// Face sparks outward along the wall normal
_wallSparkGo.WorldRotation = Rotation.LookAt( WallHitNormal, Vector3.Up );
}
}
// Ground dust effect
var trace = Scene.Trace.Ray( WorldPosition, WorldPosition + WorldRotation.Down * 50f )
.IgnoreGameObject( GameObject )
.Run();
if ( trace.Hit )
{
// TODO: spawn ground dust particle at trace.EndPosition
}
}
protected override void OnFixedUpdate()
{
if ( IsProxy ) return;
if ( !IsAlive ) return;
ProjectileSimulator?.Simulate();
SimulateInventory();
if ( IsBot ) return;
if ( _resetTimer )
RequestSuicide();
if ( Input.Down( "slot9" ) )
DebugText();
}
/// <summary>Called by PilotGame on the host each fixed update to regen shield.</summary>
public void HostFixedUpdate()
{
if ( _pendingRespawn && _respawnTime )
{
_pendingRespawn = false;
Respawn();
}
if ( !IsAlive ) return;
Shield = Shield.Clamp( 0f, Data.Shield );
if ( _timeSinceLastDamage > Data.ShieldRegenDelay )
Shield = MathF.Min( Shield + Data.ShieldRegenRate, Data.Shield );
}
// -------------------------------------------------------------------------
// Spawn / Respawn
// -------------------------------------------------------------------------
/// <summary>Schedule a respawn after <paramref name="delay"/> seconds (host only).</summary>
public void TriggerRespawn( float delay = 0.1f )
{
_respawnTime = delay;
_pendingRespawn = true;
}
public void Respawn( Transform? spawnTransform = null )
{
if ( _pendingShipData != null )
{
Data = _pendingShipData;
_pendingShipData = null;
}
// Determine spawn location
var spawn = spawnTransform ?? PilotGame.GetRandomSpawnpoint();
// Tell the owner client to teleport — they own the Rigidbody so only they can move it
TeleportToSpawn( spawn.Position, spawn.Rotation, Data.IdleSpeed, Data.MinSpeed, Data.MaxSpeed, Data.BoostAmount );
IsAlive = true;
DeathCamTarget = null;
Health = Data.Health;
MaxHealth = Data.Health;
Shield = Data.Shield;
MaxShield = PilotGame.Gamemode == FPGameMode.Instagib ? 0f : Data.Shield;
Speed = Data.IdleSpeed;
MinSpeed = Data.MinSpeed;
MaxSpeed = Data.MaxSpeed;
IdleSpeed = Data.IdleSpeed;
BoostAmount = Data.BoostAmount;
BoostSpeed = Data.BoostSpeed;
BoostRegenRate = Data.BoostRegenRate;
CappedMaxSpeed = Data.MaxSpeed;
BoostCoolDown = Data.BoostAmount;
TrailColor = Color.Random;
_primaryWeapon = Data.Primary;
_secondaryWeapon = Data.Secondary;
if ( PilotGame.Gamemode == FPGameMode.Instagib )
{
_primaryWeapon = "prefabs/guns/railgun.prefab";
_secondaryWeapon = "prefabs/guns/railgun.prefab";
}
GameObject.Tags.Add( "player" );
Components.GetOrCreate<FlightController>();
SetUpWeapons();
CreateShipSkinComponent();
ProjectileSimulator ??= new ProjectileSimulator( this );
}
[Rpc.Owner]
private void TeleportToSpawn( Vector3 position, Rotation rotation, float speed, float minSpeed, float maxSpeed, float boostCoolDown )
{
WorldPosition = position;
WorldRotation = rotation;
ViewAngles = rotation.Angles();
if ( Rigidbody != null )
{
Rigidbody.Velocity = Vector3.Zero;
Rigidbody.AngularVelocity = Vector3.Zero;
}
// Initialize owner-synced flight params (host can't write [Sync] on client-owned pawn)
Speed = speed;
MinSpeed = minSpeed;
MaxSpeed = maxSpeed;
BoostCoolDown = boostCoolDown;
}
/// <summary>
/// Called on ALL clients (including the owner) when IsAlive transitions from false to true.
/// Handles all client-side setup that can't run via the host's Respawn() call.
/// </summary>
private void OnBecameAlive()
{
CreateShip();
CreateShipSkinComponent();
if ( IsProxy || IsBot ) return;
FlightCamera ??= new FlightCamera();
SoundSetup();
Experience.Current?.Save();
}
/// <summary>Called by the owning client to request a new ship on the host.</summary>
[Rpc.Host]
public void RequestNewShip( string shipPath )
{
if ( Network.Owner != Rpc.Caller ) return;
var newData = PilotGame.Gamemode == FPGameMode.Instagib
? ResourceLibrary.Get<ShipData>( "ships/wingsship.ship" )
: ResourceLibrary.Get<ShipData>( shipPath );
if ( newData == null ) return;
var firstShipSelection = !HasSelectedShip;
_pendingShipData = newData;
HasSelectedShip = true;
if ( firstShipSelection )
UnlockAchievement( AchievementKeys.SignedInLockedIn );
if ( IsAlive )
{
// Always die first, then respawn with the new ship.
TakeDamage( 1000f, null );
}
else
{
// Already dead: spawn quickly with queued ship.
_respawnTime = 0.1f;
_pendingRespawn = true;
}
}
// -------------------------------------------------------------------------
[Property, Range( 0.05f, 2f )] public float MouseSensitivity { get; set; } = 0.5f;
[Property, Range( 0f, 0.5f )] public float ControllerAimAssistStrength { get; set; } = 0.22f;
[Property, Range( 5f, 30f )] public float ControllerAimAssistCone { get; set; } = 18f;
[Property, Range( 500f, 6000f )] public float ControllerAimAssistRange { get; set; } = 4200f;
[Property, Range( 0f, 0.5f )] public float ControllerAimTrackStrength { get; set; } = 0.16f;
[Property, Range( 0f, 25f )] public float ControllerAimAssistMaxDegreesPerTick { get; set; } = 2.5f;
private PlayerPawn _controllerTrackedTarget;
// -------------------------------------------------------------------------
private void BuildInput()
{
InputDirection = Input.AnalogMove;
var look = Input.AnalogLook * MouseSensitivity;
if( Input.UsingController )
{
look *= 1.75f; // controller look is less responsive, so boost it a bit
look += GetControllerAimAssist( look );
}
// When flying inverted the ship's local-left is world-right, so yaw
// feels backwards. Negate it whenever the ship's up vector points down.
if ( Rotation.From( ViewAngles ).Up.z < 0f )
look = new Angles( look.pitch, -look.yaw, look.roll );
ViewAngles = ( ViewAngles + look ).Normal;
BuildInventoryInput();
}
private Angles GetControllerAimAssist( Angles lookInput )
{
if ( ControllerAimAssistStrength <= 0f ) return Angles.Zero;
var stickMag = new Vector2( lookInput.yaw, lookInput.pitch ).Length;
const float StickActivationThreshold = 0.03f;
if ( stickMag < StickActivationThreshold ) return Angles.Zero;
var scene = Game.ActiveScene;
if ( scene == null ) return Angles.Zero;
var baseAngles = (ViewAngles + lookInput).Normal;
var baseRot = Rotation.From( baseAngles );
// Sticky target tracking: keep lock if still valid and inside a slightly wider cone.
var stickyCone = ControllerAimAssistCone * 1.35f;
if ( IsValidAimAssistTarget( _controllerTrackedTarget, baseRot, stickyCone, out var stickyDir, out var stickyDist, out var stickyAngle ) )
{
var stickyAngles = Rotation.LookAt( stickyDir, Vector3.Up ).Angles();
var stickyDelta = (stickyAngles - baseAngles).Normal;
var stickyScore = (1f - (stickyAngle / stickyCone)) * (1f - (stickyDist / ControllerAimAssistRange));
return BuildAimAssistOutput( stickyDelta, stickyScore, stickMag, true );
}
_controllerTrackedTarget = null;
float bestScore = 0f;
Angles bestDelta = Angles.Zero;
PlayerPawn bestTarget = null;
foreach ( var pawn in scene.GetAllComponents<PlayerPawn>() )
{
if ( pawn == null || pawn == this || !pawn.IsAlive ) continue;
if ( !pawn.IsBot ) continue; // prioritize bots for now
var toEnemy = pawn.WorldPosition - WorldPosition;
var dist = toEnemy.Length;
if ( dist <= 1f || dist > ControllerAimAssistRange ) continue;
var dir = toEnemy / dist;
var dot = baseRot.Forward.Dot( dir );
if ( dot <= 0f ) continue;
var angle = MathF.Acos( dot.Clamp( -1f, 1f ) ).RadianToDegree();
if ( angle > ControllerAimAssistCone ) continue;
var targetAngles = Rotation.LookAt( dir, Vector3.Up ).Angles();
var delta = (targetAngles - baseAngles).Normal;
// Prefer targets near center and strongly favor bots.
var score = (1f - (angle / ControllerAimAssistCone)) * (1f - (dist / ControllerAimAssistRange));
score *= 1.6f;
if ( score > bestScore )
{
bestScore = score;
bestDelta = delta;
bestTarget = pawn;
}
}
if ( bestScore <= 0f ) return Angles.Zero;
_controllerTrackedTarget = bestTarget;
return BuildAimAssistOutput( bestDelta, bestScore, stickMag, false );
}
private Angles BuildAimAssistOutput( Angles delta, float score, float stickMag, bool tracking )
{
var inputDamping = 1f - (stickMag.Clamp( 0f, 1f ) * 0.55f);
var baseStrength = tracking ? ControllerAimTrackStrength : ControllerAimAssistStrength;
var scale = baseStrength * score * inputDamping;
var assist = new Angles(
delta.pitch * scale,
delta.yaw * scale,
0f
);
// Cap per-tick assist so player stick input always dominates.
var maxStep = ControllerAimAssistMaxDegreesPerTick.Clamp( 0f, 25f );
assist.pitch = assist.pitch.Clamp( -maxStep, maxStep );
assist.yaw = assist.yaw.Clamp( -maxStep, maxStep );
return assist;
}
private bool IsValidAimAssistTarget( PlayerPawn target, Rotation baseRot, float cone, out Vector3 dir, out float dist, out float angle )
{
dir = Vector3.Zero;
dist = 0f;
angle = 0f;
if ( target == null || !target.IsAlive || !target.IsBot ) return false;
var toEnemy = target.WorldPosition - WorldPosition;
dist = toEnemy.Length;
if ( dist <= 1f || dist > ControllerAimAssistRange ) return false;
dir = toEnemy / dist;
var dot = baseRot.Forward.Dot( dir );
if ( dot <= 0f ) return false;
angle = MathF.Acos( dot.Clamp( -1f, 1f ) ).RadianToDegree();
return angle <= cone;
}
// -------------------------------------------------------------------------
// Velocity (used by FlightController) — owner writes, everyone reads
// -------------------------------------------------------------------------
[Sync]
public Vector3 Velocity { get; set; }
// -------------------------------------------------------------------------
// Trail / Effects Prefab
// -------------------------------------------------------------------------
private void CreateShip()
{
DestroyShip();
if ( string.IsNullOrEmpty( Data?.ShipPrefab ) ) return;
var prefabFile = ResourceLibrary.Get<PrefabFile>( Data.ShipPrefab );
if ( prefabFile == null ) return;
_shipGo = SceneUtility.GetPrefabScene( prefabFile )?.Clone( new CloneConfig
{
Parent = GameObject,
Transform = global::Transform.Zero,
StartEnabled = true,
} );
if ( _shipGo != null )
{
ModelRenderer = _shipGo.Components.Get<SkinnedModelRenderer>( FindMode.EnabledInSelfAndDescendants );
// Ensure shield is hidden until damagefade is driven by OnUpdate
foreach ( var r in _shipGo.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) )
r.SceneObject?.Attributes.Set( "damagefade", 0f );
}
// Create one wall-scrape spark instance in world space, disabled until we hit a wall
var sparkPrefab = ResourceLibrary.Get<PrefabFile>( "prefabs/player/wall_spark.prefab" );
if ( sparkPrefab != null )
{
_wallSparkGo = SceneUtility.GetPrefabScene( sparkPrefab )?.Clone( new CloneConfig
{
Parent = GameObject,
Transform = global::Transform.Zero,
StartEnabled = false,
} );
}
// Re-enable collision when ship is created (respawn)
foreach ( var col in Components.GetAll<Collider>( FindMode.InSelf ) )
col.Enabled = true;
}
private void DestroyShip()
{
_wallSparkGo?.Destroy();
_wallSparkGo = null;
_shipGo?.Destroy();
_shipGo = null;
ModelRenderer = null;
// Disable collision — no ship, no hitbox
foreach ( var col in Components.GetAll<Collider>( FindMode.InSelf ) )
col.Enabled = false;
}
private void CreateShipSkinComponent()
{
var skin = Components.GetOrCreate<ShipSkinComponent>();
skin.Skin = -1;
}
// -------------------------------------------------------------------------
// Sounds
// -------------------------------------------------------------------------
private const float SoundHearingRange = 3500f;
/// <summary>
/// Manages 3D sounds for ships other than the local player.
/// Only creates handles when the ship is within hearing range; stops them when it leaves.
/// For the local player's own pawn, the existing SoundSetup/UpdateSounds path handles everything.
/// </summary>
private void UpdateNearbySounds()
{
var listener = Local;
if ( listener == null || listener == this ) return; // local player handled elsewhere
var dist = WorldPosition.Distance( listener.WorldPosition );
if ( dist > SoundHearingRange )
{
// Out of range — release handles
if ( _engineSound != null )
{
StopAllSounds();
}
return;
}
var pos = WorldPosition;
// Create handles lazily when the ship enters range
if ( _engineSound == null && Data != null )
{
_engineSound = Sound.Play( "jetship_01", pos );
if ( !string.IsNullOrEmpty( Data.BoostSoundLoop ) )
_boostLoopSound = Sound.Play( Data.BoostSoundLoop, pos );
}
// Volume falls off with distance
float proximity = 1f - (dist / SoundHearingRange);
if ( _engineSound != null )
{
_engineSound.Position = pos;
_engineSound.Pitch = Speed / 11.5f;
_engineSound.Volume = 0.4f * proximity;
}
var wantsBoost = IsBot ? BotWantsBoost : BoostCoolDown > 0f && Speed > IdleSpeed * 1.3f;
if ( _boostLoopSound != null )
{
_boostLoopSound.Position = pos;
_boostLoopSound.Volume = wantsBoost ? 1.5f * proximity : 0f;
_boostLoopSound.Pitch = 0.85f;
}
}
private void SoundSetup()
{
StopAllSounds();
_engineSound = Sound.Play( "jetship_01", WorldPosition );
if ( !string.IsNullOrEmpty( Data?.BoostSoundLoop ) )
_boostLoopSound = Sound.Play( Data.BoostSoundLoop, WorldPosition );
_wallGrindSound = Sound.Play( "metalgriding", WorldPosition );
if ( _wallGrindSound != null ) _wallGrindSound.Volume = 0;
}
private void UpdateSounds( bool hitWall )
{
if ( !IsAlive ) return;
var pos = WorldPosition;
var boostActive = Input.Down( "run" ) && BoostCoolDown != 0;
if ( _boostLoopSound != null )
{
_boostLoopSound.Position = pos;
_boostLoopSound.Volume = boostActive ? 2.0f : 0f;
_boostLoopSound.Pitch = 0.85f;
}
if ( _wallGrindSound != null )
{
_wallGrindSound.Position = pos;
_wallGrindSound.Volume = hitWall ? 0.75f : 0f;
}
if ( _engineSound != null )
{
_engineSound.Position = pos;
_engineSound.Pitch = Speed / 11.5f;
_engineSound.Volume = Health > 0 ? 0.5f : 0f;
}
}
private void StopAllSounds()
{
_engineSound?.Stop();
_boostLoopSound?.Stop();
_wallGrindSound?.Stop();
_engineSound = null;
_boostLoopSound = null;
_wallGrindSound = null;
}
// -------------------------------------------------------------------------
// Damage
// -------------------------------------------------------------------------
public void TakeDamage( float damage, PlayerPawn attacker )
{
if ( !PilotGame.CanScoreAndDamage() ) return;
if ( !IsAlive ) return;
// Notify the victim of the incoming damage direction (skip bots — they have no HUD)
if ( attacker != null && attacker != this && !IsBot && Network.Owner != null )
{
using ( Rpc.FilterInclude( Network.Owner ) )
NotifyDamageDirection( attacker.WorldPosition );
}
// In instagib any weapon hit is fatal — bypass all shield/health math (wall/environment damage excluded)
if ( PilotGame.Gamemode == FPGameMode.Instagib && damage > 0f && attacker != null )
{
_timeSinceLastDamage = 0f;
if ( attacker != null && attacker != this && !attacker.IsBot )
{
using ( Rpc.FilterInclude( attacker.Network.Owner ) )
NotifyAttackerHit();
}
Health = 0f;
OnKilled( attacker );
return;
}
_timeSinceLastDamage = 0f;
if ( attacker != null && attacker != this && !attacker.IsBot )
{
// Notify attacker of hit
using ( Rpc.FilterInclude( attacker.Network.Owner ) )
NotifyAttackerHit();
}
// Shield absorbs damage first
if ( Shield > 0f && damage > 0f )
{
Shield -= damage;
if ( Shield <= 0f )
{
Shield = 0f;
if ( attacker != null && attacker != this )
{
attacker.AddStat( AchievementKeys.ShieldBreaks );
attacker._sessionShieldBreaks++;
if ( attacker._sessionShieldBreaks >= 50 )
attacker.UnlockAchievement( AchievementKeys.ShieldBreaker );
}
// TODO: shield break particle
// TODO: play shield warning sound to owner
}
}
else if ( Shield <= 0f && Health > 0f && damage > 0f )
{
Health -= damage;
if ( attacker != null && attacker != this )
{
var hullDamage = Math.Max( 1, (int)MathF.Round( damage ) );
attacker.AddStat( AchievementKeys.HullDamage, hullDamage );
attacker._sessionHullDamage += damage;
if ( attacker._sessionHullDamage >= 10000f )
attacker.UnlockAchievement( AchievementKeys.HullBreach );
}
if ( Health <= 0f )
{
Health = 0f;
OnKilled( attacker );
}
}
}
[Rpc.Broadcast]
private void NotifyAttackerHit()
{
Sound.Play( "ui.hitmarker" );
HitMarker.Current?.OnHit();
}
[Rpc.Broadcast]
private void NotifyDamageDirection( Vector3 attackerWorldPos )
{
// Guard: only show on the client who owns this pawn
if ( PlayerPawn.Local != this ) return;
DamageIndicator.Current?.AddHit( attackerWorldPos );
}
private void OnKilled( PlayerPawn attacker )
{
if ( !PilotGame.CanScoreAndDamage() ) return;
IsAlive = false;
// Spawn ragdoll on all clients (must come BEFORE DestroyShip — it reads ModelRenderer)
CreateDeath();
DestroyShip();
// Notify kill — always show in feed
bool attackerIsBot = attacker?.IsBot ?? false;
bool showInFeed = true;
string attackerName = attackerIsBot ? (attacker?.BotName ?? "Bot") : (attacker?.Network.Owner?.DisplayName ?? "World");
string victimName = IsBot ? (BotName ?? "Bot") : (Network.Owner?.DisplayName ?? "Bot");
if ( attacker != null && attacker != this )
{
attacker.Kills++;
Deaths++;
int points = IsBot ? 1 : 10;
attacker.Score += points;
attacker.AddStat( "kills" );
attacker.AddStat( "score", points );
attacker.AddStat( "xp", points );
attacker.OnScoredKill();
AddStat( "deaths" );
AddStat( "xp", -2 );
if ( showInFeed )
PilotGame.BroadcastKillMessage( attackerName, victimName, "eliminated" );
}
else
{
Deaths++;
AddStat( "deaths" );
AddStat( "xp", -2 );
if ( showInFeed )
PilotGame.BroadcastKillMessage( "", victimName, "suicide" );
}
if ( !IsProxy )
{
StopAllSounds();
}
if ( !IsProxy && !IsBot )
{
ShipListHint.Current?.ShowAttacker( (attacker == null || attacker == this) ? "Suicide" : $"{attackerName} killed you" );
_deathScreenActive = true;
_deathScreenHideTime = 3f;
// Point death cam at killer (or null = stay put)
DeathCamTarget = (attacker != null && attacker != this) ? attacker : null;
FlightCamera?.OnDied( WorldPosition );
}
// Schedule respawn — bots are managed by BotSpawner instead
if ( Networking.IsHost && !IsBot )
{
_respawnTime = 3f;
_pendingRespawn = true;
}
}
[Rpc.Owner]
private void PlaySoundOnOwner( string eventName )
{
Sound.Play( eventName );
}
/// <summary>Called on the owning client to record a stat to s&box services.</summary>
[Rpc.Broadcast]
private void RpcAddStat( string identifier, int amount = 1 )
{
Sandbox.Services.Stats.Increment( identifier, amount );
}
[Rpc.Broadcast( NetFlags.HostOnly )]
private void RpcUnlockAchievement( string identifier )
{
Sandbox.Services.Achievements.Unlock( identifier );
}
/// <summary>Host-only: increment a persistent stat for this player. Skips bots and cheat mode.</summary>
public void AddStat( string identifier, int amount = 1 )
{
if ( !Networking.IsHost ) return;
if ( IsBot ) return;
if ( Application.CheatsEnabled ) return;
var owner = Network.Owner;
if ( owner == null ) return;
using ( Rpc.FilterInclude( owner ) )
{
RpcAddStat( identifier, amount );
}
}
/// <summary>Host-only: unlock an achievement for this player owner.</summary>
public void UnlockAchievement( string identifier )
{
if ( !Networking.IsHost ) return;
if ( IsBot ) return;
if ( Application.CheatsEnabled ) return;
if ( string.IsNullOrWhiteSpace( identifier ) ) return;
var owner = Network.Owner;
if ( owner == null ) return;
using ( Rpc.FilterInclude( owner ) )
{
RpcUnlockAchievement( identifier );
}
}
public void TrackBoostUsage( float seconds )
{
if ( !Networking.IsHost ) return;
if ( IsBot ) return;
if ( seconds <= 0f ) return;
_sessionBoostSeconds += seconds;
_boostStatAccumulator += seconds;
if ( _boostStatAccumulator >= 1f )
{
var wholeSeconds = Math.Max( 1, (int)_boostStatAccumulator );
AddStat( AchievementKeys.BoostSeconds, wholeSeconds );
_boostStatAccumulator -= wholeSeconds;
}
if ( _sessionBoostSeconds >= 300f )
UnlockAchievement( AchievementKeys.Afterburner );
}
public void OnMatchCompleted( bool wonMatch )
{
if ( !Networking.IsHost ) return;
if ( IsBot ) return;
if ( !HasSelectedShip ) return;
AddStat( AchievementKeys.MatchesCompleted );
UnlockAchievement( AchievementKeys.FirstFlight );
_sessionMatchesCompleted++;
if ( _sessionMatchesCompleted >= 100 )
UnlockAchievement( AchievementKeys.VeteranPilot );
if ( !wonMatch ) return;
AddStat( AchievementKeys.WinsTotal );
if ( Deaths <= 0 )
UnlockAchievement( AchievementKeys.Unbreakable );
if ( PilotGame.Gamemode == FPGameMode.Instagib )
UnlockAchievement( AchievementKeys.InstaKing );
var shipKey = $"fp4_wins_ship_{SanitizeStatToken( Data?.ResourcePath )}";
AddStat( shipKey );
if ( _sessionWonShips.Add( shipKey ) && _sessionWonShips.Count >= 3 )
UnlockAchievement( AchievementKeys.ShipCollector );
var shipWins = _sessionShipWinCounts.TryGetValue( shipKey, out var existing ) ? existing + 1 : 1;
_sessionShipWinCounts[shipKey] = shipWins;
if ( shipWins >= 5 )
UnlockAchievement( AchievementKeys.Loyalist );
}
private void OnScoredKill()
{
UnlockAchievement( AchievementKeys.WeaponsHot );
if ( Kills >= 5 )
UnlockAchievement( AchievementKeys.AcePilot );
if ( Kills >= 10 )
UnlockAchievement( AchievementKeys.TopGun );
if ( Health <= 15f )
UnlockAchievement( AchievementKeys.CloseCall );
if ( BreakLean > 0.35f )
UnlockAchievement( AchievementKeys.DriftMaster );
if ( PilotGame.Gamemode == FPGameMode.Instagib )
{
AddStat( AchievementKeys.InstagibKills );
_sessionInstagibKills++;
if ( _sessionInstagibKills >= 25 )
UnlockAchievement( AchievementKeys.RailScholar );
}
}
private static string SanitizeStatToken( string value )
{
if ( string.IsNullOrWhiteSpace( value ) )
return "unknown";
var chars = value.ToLowerInvariant().Select( c => char.IsLetterOrDigit( c ) ? c : '_' ).ToArray();
return new string( chars ).Trim( '_' );
}
/// <summary>Called by the owning client to request suicide.</summary>
[Rpc.Host]
public void RequestSuicide()
{
if ( Network.Owner != Rpc.Caller ) return;
TakeDamage( 1000f, null );
}
/// <summary>Called by the owning client when they hit a wall — applies damage on the host.</summary>
[Rpc.Host]
public void RequestWallDamage( float damage )
{
if ( Network.Owner != Rpc.Caller ) return;
TakeDamage( damage, null );
}
// -------------------------------------------------------------------------
// Debug
// -------------------------------------------------------------------------
private void DebugText()
{
DebugOverlay.ScreenText( new Vector2( 40, 40 ), $"Ship: {Data.ShipName}" );
DebugOverlay.ScreenText( new Vector2( 40, 60 ), $"Speed: {Speed:F1}" );
DebugOverlay.ScreenText( new Vector2( 40, 80 ), $"Velocity: {Velocity}" );
DebugOverlay.ScreenText( new Vector2( 40, 100 ), $"Health: {Health:F0} / Shield: {Shield:F0}" );
DebugOverlay.ScreenText( new Vector2( 40, 120 ), $"Boost: {BoostCoolDown:F0} / {BoostAmount:F0}" );
DebugOverlay.ScreenText( new Vector2( 40, 140 ), $"Position: {WorldPosition}" );
}
// -------------------------------------------------------------------------
// Out-of-bounds timer
// -------------------------------------------------------------------------
private bool _resetTimer = false;
private TimeSince _timeUntilReset;
public void SetOutOfBounds( bool outOfBounds )
{
_resetTimer = outOfBounds;
if ( outOfBounds )
_timeUntilReset = 0;
}
// -------------------------------------------------------------------------
// FlightCamera reference (local only)
// -------------------------------------------------------------------------
public FlightCamera FlightCamera { get; private set; }
// -------------------------------------------------------------------------
// Aim Ray
// -------------------------------------------------------------------------
public Ray AimRay => new Ray( WorldPosition, WorldRotation.Forward );
// -------------------------------------------------------------------------
// Projectile simulator
// -------------------------------------------------------------------------
public ProjectileSimulator ProjectileSimulator { get; private set; }
// -------------------------------------------------------------------------
// INetworkListener
// -------------------------------------------------------------------------
public void OnActive( Connection channel ) { }
public void OnDisconnected( Connection channel ) { }
}