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