Pawn/SkatePawn.cs

SkatePawn component for the player skater. Manages renderers, controller/input/animator links, respawn and bail/ragdoll logic, trick scoring updates, sound and trick animation RPCs, and some clothing/dresser handling.

NetworkingFile Access
using Skateboard.Tricks;
using Skateboard.Utils;
using System;

namespace Skateboard.Player;

public sealed class SkatePawn : Component
{
	public static SkatePawn Local => Game.ActiveScene.GetAll<SkatePawn>().FirstOrDefault( x => x.Network.IsOwner );
	public enum BailType { Normal, Landing, Bail }

	[Property] public SkinnedModelRenderer BodyRenderer { get; set; }
	[Property] public SkinnedModelRenderer BoardRenderer { get; set; }
	[Property] public GameObject Head { get; set; }
	[Property] public string BodyModel { get; set; } = "models/skateanimations.vmdl";
	[Property] public string BoardModel { get; set; } = "models/skateboard_animated.vmdl";

	[Property] public TrickScoreHolder TrickScores { get; set; }
	[Property] public SkateController Controller { get; set; }
	[Property] public SkateAnimator Animator { get; set; }
	[Property] public SkateInput Input { get; set; }

	[Property] public SkateAudioHelper SkateAudio { get; set; }
	[Property] public GrindAudioHelper GrindAudio { get; set; }

	[Sync] public float TurnRight { get; set; }
	[Sync] public bool Crouch { get; set; }
	[Sync] public bool OnVert { get; set; }
	[Sync] public Vector3 VertNormal { get; set; }
	[Sync] public Vector3 Velocity { get; set; }
	[Sync] public Rotation RealRotation { get; set; }
	[Sync] public Guid OwningConnectionId { get; set; }

	[Sync] public float BailTime { get; set; } = 1.5f;
	[Sync] public bool Bailed { get; private set; }

	[Property] public float RespawnHoldTime { get; set; } = 0.6f;
	[Property] public float MinBailSkipTime { get; set; } = 0.25f;
	private float _respawnHeldFor;
	private int _bestComboThisSession;

	[Sync] public Vector3 EyeLocalPosition { get; set; }
	[Sync] public Rotation EyeLocalRotation { get; set; }

	private float _timeBailed;

	public GameObject Ragdoll { get; private set; }
	public GameObject BoardRagdoll { get; private set; }

	public Vector3 EyePosition
	{
		get => WorldTransform.PointToWorld( EyeLocalPosition );
		set => EyeLocalPosition = WorldTransform.PointToLocal( value );
	}

	public Rotation EyeRotation
	{
		get => WorldTransform.RotationToWorld( EyeLocalRotation );
		set => EyeLocalRotation = WorldTransform.RotationToLocal( value );
	}

	public bool Grinding => Controller?.OnGrind ?? false;

	protected override void OnStart()
	{
		EnsureRenderers();

		Input ??= Components.GetOrCreate<SkateInput>();
		Controller ??= Components.GetOrCreate<SkateController>();
		Animator ??= Components.GetOrCreate<SkateAnimator>();
		TrickScores ??= Components.GetOrCreate<TrickScoreHolder>();

		SkateAudio ??= Components.GetOrCreate<SkateAudioHelper>();
		GrindAudio ??= Components.GetOrCreate<GrindAudioHelper>();

		var dresser = GetComponent<Dresser>();
		dresser?.Apply();
		// Components.GetOrCreate<Skateboard.ClothingModelSuffixStripper>();

		Controller.Pawn = this;
		Controller.Input = Input;
		Controller.TrickScores = TrickScores;
		Controller.Animator = Animator;

		Input.Pawn = this;

		Animator.Pawn = this;
		Animator.Target = BodyRenderer;
		Animator.BoardRenderer = BoardRenderer;

		SkateAudio.Pawn = this;
		GrindAudio.Pawn = this;

		TrickScores.OnComboFinish += OnComboFinish;
		TrickScores.OnComboFailed += OnComboFail;

		TurnRight = 0f;
		Crouch = false;
		RealRotation = WorldRotation;

		Velocity = 0f;
		Bailed = false;
		_timeBailed = 0f;

		Tags.Add( "player" );
	}

	protected override void OnFixedUpdate()
	{
		if ( !IsLocallyControlled() )
			return;

		BodyRenderer.Model = Model.Load( BodyModel );

		if ( Bailed )
		{
			_timeBailed += Time.Delta;

			// Let the player skip the ragdoll wait once a short grace has passed.
			bool skip = Input.IsValid() && _timeBailed >= MinBailSkipTime
				&& (Input.JumpPressed || Input.RespawnHeld);

			if ( _timeBailed >= BailTime || skip )
				RespawnAfterBail();
			return;
		}

		// Hold-to-respawn lets a stuck player (e.g. glitched under the map)
		// reset back to a spawn point without having to restart the map.
		if ( Input.IsValid() && Input.RespawnHeld )
		{
			_respawnHeldFor += Time.Delta;
			if ( _respawnHeldFor >= RespawnHoldTime )
			{
				_respawnHeldFor = 0f;
				ForceRespawn();
			}
		}
		else
		{
			_respawnHeldFor = 0f;
		}
	}

	/// <summary>
	/// Teleport the skater to an arbitrary transform (teleport volumes, portals, etc),
	/// keeping the controller's rotation/velocity state consistent.
	/// </summary>
	public void TeleportTo( Transform transform, bool keepVelocity )
	{
		WorldTransform = transform;

		if ( keepVelocity )
		{
			RealRotation = WorldRotation;
		}
		else
		{
			Controller?.ResetState();
		}
	}

	public void ForceRespawn()
	{
		if ( !IsLocallyControlled() )
			return;

		Bailed = false;
		_timeBailed = 0f;

		Respawn( toSpawnPoint: true );
	}

	private void EnsureRenderers()
	{
		BodyRenderer ??= Components.GetOrCreate<SkinnedModelRenderer>();
		BodyRenderer.Model ??= Model.Load( BodyModel );

		if ( BoardRenderer.IsValid() )
		{
			BoardRenderer.Model ??= Model.Load( BoardModel );
			return;
		}

		var boardObject = new GameObject( GameObject, true, "Board" );
		BoardRenderer = boardObject.Components.GetOrCreate<SkinnedModelRenderer>();
		BoardRenderer.Model ??= Model.Load( BoardModel );
	}

	internal bool HasHelmet()
	{
		foreach ( var child in GameObject.Children )
		{
			if ( !child.IsValid() )
				continue;

			var renderer = child.Components.Get<ModelRenderer>();
			if ( renderer?.Model is null )
				continue;

			var modelName = renderer.Model.Name?.ToLowerInvariant() ?? string.Empty;
			if ( modelName.Contains( "helmet" ) || modelName.Contains( "hardhat" ) )
				return true;
		}

		return false;
	}

	public void Bail( BailType bailType = BailType.Normal )
	{
		if ( Bailed || !IsLocallyControlled() )
			return;

		TrickScores.Failed = true;
		Bailed = true;

		var hasHelmet = HasHelmet();
		var headPos = WorldPosition + WorldRotation.Up * 70f;

		if ( bailType == BailType.Landing )
		{
			if ( !hasHelmet )
			{
				PlaySound( "impact-bullet-flesh", headPos );
			}
			else
			{
				PlaySound( "helmet_hit", headPos );
			}

			var to = headPos + Vector3.Down * 50f;
			var headTr = Scene.Trace.Ray( headPos, to ).WithAnyTags( "solid", "player", "npc" ).Run();
			if ( headTr.Hit )
			{
				// Decal placement can be added back if needed.
			}
		}

		if ( bailType != BailType.Bail )
			PlaySound( "body_fall", WorldPosition );

		Head.Enabled = false;


		CreateRagdoll();

		BodyRenderer.Enabled = false;
		Controller.Enabled = false;
		Animator.Enabled = false;
		BoardRenderer.Enabled = false;

		_timeBailed = 0f;
	}

	public void CreateRagdoll()
	{
		if ( BodyRenderer is null )
			return;

		CreateBoardRagdoll();

		var ragdollObject = BodyRenderer.GameObject.Clone(
			BodyRenderer.WorldTransform,
			Game.ActiveScene,
			startEnabled: false,
			name: $"{GameObject.Name}_ragdoll"
		);
		Ragdoll = ragdollObject;
		DisableRagdollDressers( ragdollObject );
		ragdollObject.Enabled = true;
		SetClothingEnabled( BodyRenderer.GameObject, false );
		SetClothingEnabled( ragdollObject, true );

		var bodyModel = ragdollObject.Components.Get<SkinnedModelRenderer>();
		if ( bodyModel is null )
			return;

		ragdollObject.DestroyAsync( 5f );
		bodyModel.UseAnimGraph = false;

		foreach ( var skinnedModelRenderer in bodyModel.Components.GetAll<SkinnedModelRenderer>() )
		{
			skinnedModelRenderer.RenderType = ModelRenderer.ShadowRenderType.On;
		}

		bodyModel.Tags.Add( "ragdoll" );

		var modelPhysics = bodyModel.GetOrAddComponent<ModelPhysics>();
		modelPhysics.Model = bodyModel.Model;
		modelPhysics.Renderer = bodyModel;
		modelPhysics.MotionEnabled = true;

		foreach ( var body in modelPhysics.Bodies )
		{
			body.Component.Velocity += Velocity;
		}
	}

	private void RespawnAfterBail()
	{
		RealRotation = MathLD.FromToRotation( Vector3.Up * RealRotation, Vector3.Up ) * RealRotation;
		WorldRotation = RealRotation;

		// Try to recover near where we bailed. Only fall back to a map spawn
		// point if no safe ground was found around the bail location.
		var respawn = RespawnHelper.FindRespawnPosition( WorldPosition );
		if ( respawn.Found )
		{
			WorldPosition = respawn.Location;
			Respawn( toSpawnPoint: false );
			return;
		}

		Respawn( toSpawnPoint: true );
	}

	public void Respawn( bool toSpawnPoint = true )
	{
		if ( toSpawnPoint )
		{
			// Pick the closest spawn point so a manual respawn drops you back near
			// where you were rather than flinging you across the map.
			var here = WorldPosition;
			var spawn = Scene.GetAllComponents<SpawnPoint>()
				.OrderBy( s => Vector3.DistanceBetween( s.WorldPosition, here ) )
				.FirstOrDefault();
			if ( spawn is not null )
			{
				WorldTransform = spawn.WorldTransform.WithPosition( spawn.WorldPosition + Vector3.Up * 50f );
			}
		}

		Controller.Enabled = true;
		Animator.Enabled = true;
		BodyRenderer.Enabled = true;
		BoardRenderer.Enabled = true;
		Head.Enabled = true;

		Bailed = false;
		_timeBailed = 0f;
		Velocity = 0f;
		Ragdoll = null;
		BoardRagdoll = null;
		SetClothingEnabled( BodyRenderer.GameObject, true );
		Controller.ResetState();
	}

	private void CreateBoardRagdoll()
	{
		if ( !BoardRenderer.IsValid() )
			return;

		var boardObject = BoardRenderer.GameObject;
		var boardRagdoll = boardObject.Clone(
			boardObject.WorldTransform,
			Game.ActiveScene,
			startEnabled: false,
			name: $"{boardObject.Name}_ragdoll"
		);

		BoardRagdoll = boardRagdoll;

		var boardRenderer = boardRagdoll.Components.Get<SkinnedModelRenderer>();
		if ( boardRenderer.IsValid() )
		{
			boardRenderer.UseAnimGraph = false;
			boardRenderer.BoneMergeTarget = null;
			var ragdollModel = Model.Load( "models/skateboard.vmdl" );
			boardRenderer.Model = ragdollModel;
		}

		var collider = boardRagdoll.Components.GetOrCreate<ModelCollider>();
		if ( boardRenderer.IsValid() )
			collider.Model = boardRenderer.Model;

		var body = boardRagdoll.Components.GetOrCreate<Rigidbody>();
		body.MotionEnabled = true;
		body.Velocity = Velocity;

		boardRagdoll.Enabled = true;
		boardRagdoll.DestroyAsync( 5f );
	}

	private static void SetClothingEnabled( GameObject root, bool enabled )
	{
		if ( root is null )
			return;

		foreach ( var child in root.GetAllObjects( false ) )
		{
			if ( child.Tags.Has( "clothing" ) )
				child.Enabled = enabled;
		}
	}

	private static void DisableRagdollDressers( GameObject root )
	{
		if ( root is null )
			return;

		foreach ( var dresser in root.Components.GetAll<Dresser>( FindMode.EverythingInSelfAndDescendants ) )
		{
			dresser.Enabled = false;
		}

		foreach ( var stripper in root.Components.GetAll<Skateboard.ClothingModelSuffixStripper>( FindMode.EverythingInSelfAndDescendants ) )
		{
			stripper.Enabled = false;
		}
	}

	private void OnComboFinish( int comboScore )
	{
		ComboFinishClient( comboScore );
		ClientTrickUpdate();
	}

	private void OnComboFail()
	{
		ClientTrickUpdate();
	}

	[Rpc.Owner]
	private void ComboFinishClient( int comboScore )
	{
		Sound.Play( "combo_finish", WorldPosition );
		RecordScoreStat( comboScore );
	}

	// Runs on the owning player's client, so the stat is submitted against
	// their Steam account. Stats only persist once the game is published and
	// the "total_score" stat is defined in the s&box game dashboard.
	private void RecordScoreStat( int comboScore )
	{
		if ( !GameObject.Network.IsOwner && OwningConnectionId != Connection.Local.Id )
			return;

		if ( comboScore <= 0 )
			return;

		Sandbox.Services.Stats.Increment( "total_score", comboScore );
		Sandbox.Services.Stats.Increment( "combos_landed", 1 );

		// Best single combo. Submit only when we beat our session best; the
		// backend keeps the all-time max across sessions (use "Highest"
		// aggregation on the "best_combo" stat in the game dashboard).
		if ( comboScore > _bestComboThisSession )
		{
			_bestComboThisSession = comboScore;
			Sandbox.Services.Stats.SetValue( "best_combo", comboScore );
		}
	}

	[Rpc.Owner]
	private void ClientTrickUpdate()
	{
		if ( GameObject.Network.IsOwner )
			TrickScoreHolder.OnLocalTrickScoreUpdate?.Invoke();
	}

	public void PlaySound( string eventName, Vector3 position )
	{
		if ( Networking.IsActive && !IsLocallyControlled() )
			return;

		PlaySoundRpc( eventName, position );
	}

	public void PlayTrickAnim( string trickName, bool isGrab )
	{
		if ( Networking.IsActive && !IsLocallyControlled() )
			return;

		PlayTrickAnimRpc( trickName, isGrab );
	}

	[Rpc.Broadcast( NetFlags.Unreliable )]
	private void PlayTrickAnimRpc( string trickName, bool isGrab )
	{
		Animator?.PlayTrick( trickName, isGrab );
	}

	private bool IsLocallyControlled()
	{
		if ( !Networking.IsActive )
			return true;

		if ( GameObject.Network.IsOwner )
			return true;

		return OwningConnectionId == Connection.Local.Id;
	}


	[Rpc.Broadcast( NetFlags.Unreliable )]
	private void PlaySoundRpc( string eventName, Vector3 position )
	{
		Sound.Play( eventName, position );
	}

	[ConCmd( "kill" )]
	public static void Kill()
	{
		Local.Bail();
	}
}