Game/PlayerAvatar.cs
namespace Monolith;
/// <summary>
/// The player's body: a Terry wearing whatever the local user has actually configured, plus the
/// third person camera that looks at him.
///
/// Two things are worth knowing about how this is wired.
///
/// **The rig stays at the eye position.** All the movement, aiming and muzzle code works in eye
/// space and none of it changes here. The body is a child hung DOWN from the rig by
/// <see cref="PlayerMovement.EyeHeight"/>, and the camera is a child pulled BACK from it. That
/// keeps a third person view from touching a single line of the movement or firing code.
///
/// **The clothing comes from the user, not from us.** `ClothingContainer.CreateFromLocalUser()`
/// reads the avatar the player set up in s&box itself, so every skin, hat and colourway they
/// own works with no per-item support on our side.
/// </summary>
public sealed class PlayerAvatar : Component
{
/// <summary>The object holding the camera. Created by GameBootstrap, positioned here.</summary>
[Property] public GameObject CameraObject { get; set; }
/// <summary>How far behind the eye the camera sits.</summary>
[Property] public float CameraDistance { get; set; } = 165f;
/// <summary>How far above the eye, so the body does not sit in the middle of the crosshair.</summary>
[Property] public float CameraHeight { get; set; } = 26f;
/// <summary>Sideways offset. Over the shoulder keeps the crosshair clear of your own head.</summary>
[Property] public float CameraShoulder { get; set; } = 34f;
private GameObject bodyObject;
private SkinnedModelRenderer body;
private GameObject gunObject;
private static Model gunModel;
/// <summary>
/// The player's own avatar, kept so the drone heads can wear it too. Built once: the call
/// reads user data and is not something to do per drone per frame.
/// </summary>
private ClothingContainer clothing;
private PlayerMovement movement;
private Miner miner;
/// <summary>
/// Where bolts actually leave from: the tip of the barrel in Terry's right hand.
///
/// This exists because the muzzle used to be derived from the CAMERA
/// (`camera.WorldPosition + forward*30 + right*14 + down*12`). In first person that reads as
/// firing from just off-screen. In third person the camera is behind and to the right of the
/// body, so every bolt visibly spawned in the bottom right corner of the screen with nothing
/// attached to it.
/// </summary>
public Vector3 MuzzlePosition { get; private set; }
/// <summary>False until the gun exists, so the Miner knows to fall back to the camera.</summary>
public bool HasMuzzle { get; private set; }
/// <summary>Yaw the body is currently facing. Turns to follow movement, not the camera.</summary>
private float bodyYaw;
protected override void OnStart()
{
movement = Components.Get<PlayerMovement>();
miner = Components.Get<Miner>();
bodyObject = new GameObject( true, "Terry" );
bodyObject.NetworkMode = NetworkMode.Never;
bodyObject.Parent = GameObject;
body = bodyObject.AddComponent<SkinnedModelRenderer>();
body.Model = Model.Load( "models/citizen/citizen.vmdl" );
DressFromLocalUser();
CreateGun();
}
private void CreateGun()
{
gunObject = new GameObject( true, "Drill Gun" );
gunObject.NetworkMode = NetworkMode.Never;
var renderer = gunObject.AddComponent<ModelRenderer>();
renderer.Model = GetGunModel();
renderer.Tint = new Color( 0.62f, 0.66f, 0.74f );
// A little light at the business end, so the gun reads as the source of the bolts even
// before one is fired.
var light = gunObject.AddComponent<PointLight>();
light.LightColor = new Color( 1f, 0.7f, 0.32f ) * 2.2f;
light.Radius = 190f;
light.Shadows = false;
}
/// <summary>
/// Applies the player's own s&box avatar. Wrapped because a user with no avatar set, or a
/// build where the clothing service is unavailable, must not take the whole rig down with it:
/// an undressed Terry is a far better outcome than no player at all.
/// </summary>
private void DressFromLocalUser()
{
try
{
clothing = ClothingContainer.CreateFromLocalUser();
clothing?.Apply( body );
}
catch ( Exception e )
{
Log.Warning( $"Could not load the local avatar, using a bare Terry: {e.Message}" );
}
}
protected override void OnUpdate()
{
// Frozen while a blocking screen or the pause menu is up. See GameTime.
if ( GameTime.Paused )
return;
if ( !body.IsValid() || !movement.IsValid() )
return;
// Hang the body from the eye down to the floor.
bodyObject.LocalPosition = Vector3.Down * movement.EyeHeight;
var velocity = movement.Velocity.WithZ( 0 );
// The body turns to face where you are MOVING, not where you are looking, so a strafe
// reads as a strafe. It only turns while there is real speed, otherwise standing still
// and sweeping the mouse would spin him on the spot.
if ( velocity.Length > 40f )
bodyYaw = velocity.Normal.EulerAngles.yaw;
else
bodyYaw = WorldRotation.Angles().yaw;
bodyObject.WorldRotation = Rotation.FromYaw( bodyYaw );
var local = Rotation.FromYaw( bodyYaw ).Inverse * velocity;
Anim( "move_x", local.x );
Anim( "move_y", local.y );
Anim( "move_z", movement.Velocity.z );
Anim( "move_groundspeed", velocity.Length );
Anim( "b_grounded", movement.IsGrounded );
// AIMING. This is what makes the gun look held rather than stuck to him.
//
// Setting holdtype alone leaves the arms hanging at his sides: the citizen graph only
// raises them when it has something to aim AT, which means aim_body plus a non-zero
// aim_body_weight. Without those the pose is idle and a gun pointed at the crosshair
// reads as a floating prop, which is exactly how it looked.
//
// Holdtype 1 is the one-handed pistol pose. Rifle (2) is two-handed and leaves the left
// hand gripping empty air next to a gun that is only in the right.
Anim( "holdtype", 1 );
Anim( "holdtype_handedness", 0 );
Anim( "aim_body_weight", 1f );
var look = WorldRotation.Forward;
AnimLook( "aim_body", look );
AnimLook( "aim_head", look );
AnimLook( "aim_eyes", look );
}
// Each parameter is set independently on purpose. They used to share one try block, so the
// first name the graph did not recognise silently skipped every parameter after it, which is
// how holdtype ended up never being applied at all.
private void Anim( string name, float value )
{
try { body.Set( name, value ); } catch ( Exception ) { }
}
private void Anim( string name, int value )
{
try { body.Set( name, value ); } catch ( Exception ) { }
}
private void Anim( string name, bool value )
{
try { body.Set( name, value ); } catch ( Exception ) { }
}
private void AnimLook( string name, Vector3 direction )
{
try { body.SetLookDirection( name, direction ); } catch ( Exception ) { }
}
/// <summary>
/// Camera placement runs in OnPreRender so it is guaranteed to happen after movement has
/// finished writing the rig position for the frame. Doing it in OnUpdate leaves it at the
/// mercy of component ordering, which shows up as a camera that judders a frame behind.
/// </summary>
protected override void OnPreRender()
{
PlaceGun();
UpdateDrones();
if ( !CameraObject.IsValid() )
return;
// Identity local rotation means the boom inherits the rig's full rotation, pitch
// included, so looking up and down orbits the camera the way it should.
// Source axes: +X forward, +Y left, +Z up. Negative Y is therefore the right shoulder.
CameraObject.LocalRotation = Rotation.Identity;
// The shake is ADDED here rather than written by the effect itself, because this line
// runs every frame and would overwrite anything it wrote.
CameraObject.LocalPosition = new Vector3( -CameraDistance, -CameraShoulder, CameraHeight )
+ StageLostEffect.ShakeOffset;
UpdateSpeedFov();
}
/// <summary>
/// Widens the field of view with hop speed.
///
/// Devil Daggers gives a perfect hop an FOV increase as its reward (GOALS 6d), and it is the
/// cheapest speed cue in games: you feel fast because the world stretches, with no HUD and no
/// number. Eased rather than snapped, because a per-frame FOV jump reads as a glitch.
/// </summary>
private void UpdateSpeedFov()
{
var camera = CameraObject.Components.Get<CameraComponent>();
if ( !camera.IsValid() || !movement.IsValid() )
return;
float wanted = Tuning.BaseFieldOfView + movement.HopCharge * Tuning.HopFovBoost;
camera.FieldOfView = MathX.Lerp( camera.FieldOfView, wanted,
MathF.Min( 1f, Time.Delta * 7f ) );
}
/// <summary>
/// Puts the gun in Terry's right hand and points it where you are aiming.
///
/// Position comes from the hand bone but rotation does NOT: the animgraph decides where the
/// hand is, which is nowhere near the crosshair. Taking the position from the skeleton and
/// the aim from the player is what makes the bolt leave the barrel and arrive under the
/// crosshair at the same time.
/// </summary>
private void PlaceGun()
{
if ( !gunObject.IsValid() || !body.IsValid() )
return;
var hand = WorldPosition;
bool found = false;
try
{
if ( body.TryGetBoneTransform( "hand_R", out var bone ) )
{
hand = bone.Position;
found = true;
}
}
catch ( Exception )
{
// Bone names belong to the citizen addon. Falling back is better than failing.
}
if ( !found )
{
// Roughly where a held hand sits: forward, right, and a little below the eye.
var yaw = bodyObject.IsValid() ? bodyObject.WorldRotation : WorldRotation;
hand = WorldPosition + yaw.Forward * 22f + yaw.Right * 14f + Vector3.Down * 16f;
}
var aim = miner.IsValid() ? miner.AimPoint : WorldPosition + WorldRotation.Forward * 1000f;
var direction = (aim - hand);
// Sits slightly forward of the wrist rather than dead on the bone, which is the
// difference between held and skewered.
gunObject.WorldPosition = hand;
if ( direction.Length > 1f )
{
var aimRotation = Rotation.LookAt( direction.Normal );
// Blended toward the aim rather than snapped to it. With aim_body driving the arm the
// hand already points roughly at the target, so a hard snap fights the animation and
// makes the gun swim inside the fist; easing lets the two agree.
gunObject.WorldRotation = HasMuzzle
? Rotation.Lerp( gunObject.WorldRotation, aimRotation,
MathF.Min( 1f, Time.Delta * 18f ) )
: aimRotation;
gunObject.WorldPosition = hand + aimRotation.Forward * 3f;
}
// The barrel runs along local forward, so the muzzle is simply out along it. Scaled with
// the model when it shrank.
MuzzlePosition = gunObject.WorldPosition + gunObject.WorldRotation.Forward * 17f;
HasMuzzle = true;
}
/// <summary>Keeps the arch of drones matching the upgrade and hanging above you.</summary>
private void UpdateDrones()
{
var progress = PlayerProgress.Local;
if ( !progress.IsValid() )
return;
int wanted = progress.DronesDisabled ? 0 : progress.DroneCount;
MiningDrone.MatchPopulation( Scene, wanted );
if ( MiningDrone.All.Count == 0 )
return;
var aim = miner.IsValid() ? miner.AimPoint : WorldPosition + WorldRotation.Forward * 1000f;
// Yaw only. Passing the full rotation would tip the whole arch into the floor whenever
// you looked down.
float yaw = WorldRotation.Angles().yaw;
for ( int i = 0; i < MiningDrone.All.Count; i++ )
MiningDrone.All[i].Follow( WorldPosition, yaw, aim, i, MiningDrone.All.Count );
}
/// <summary>
/// A blocky slab of a gun, built the same way as everything else here: a hand-rolled vertex
/// buffer, so it matches the voxel look instead of importing a detailed weapon into a game
/// made of cubes.
/// </summary>
private static Model GetGunModel()
{
if ( gunModel != null )
return gunModel;
var vb = new VertexBuffer();
vb.Init( true );
int index = 0;
// Body, then a barrel running out along +X, then a grip hanging below.
//
// Roughly HALF the first version. Terry is 64 units tall and the original was a 44 unit
// slab, which is a rifle the size of his torso. A hand-held thing has to be sized against
// the hand, not against the shape you are shooting at.
AddBox( vb, ref index, new Vector3( 2f, 0f, 0f ), new Vector3( 13f, 4.5f, 5.5f ) );
AddBox( vb, ref index, new Vector3( 12f, 0f, 0.8f ), new Vector3( 9f, 2.5f, 2.5f ) );
AddBox( vb, ref index, new Vector3( -1f, 0f, -4.5f ), new Vector3( 4f, 3f, 6f ) );
var mesh = new Mesh( Material.Load( "materials/default.vmat" ) );
mesh.CreateBuffers( vb );
gunModel = new ModelBuilder().AddMesh( mesh ).Create();
return gunModel;
}
private static void AddBox( VertexBuffer vb, ref int index, Vector3 centre, Vector3 size )
{
Vector3[] normals =
{
Vector3.Forward, Vector3.Backward, Vector3.Left,
Vector3.Right, Vector3.Up, Vector3.Down,
};
foreach ( var n in normals )
{
var reference = MathF.Abs( n.z ) > 0.9f ? Vector3.Forward : Vector3.Up;
var u = Vector3.Cross( n, reference ).Normal;
var v = Vector3.Cross( n, u ).Normal;
var face = centre + n * 0.5f * Project( size, n );
var du = u * 0.5f * Project( size, u );
var dv = v * 0.5f * Project( size, v );
var p0 = face - du - dv;
var p1 = face + du - dv;
var p2 = face + du + dv;
var p3 = face - du + dv;
vb.Add( new Vertex( p0, n, u, new Vector4( 0, 0, 0, 0 ) ) );
vb.Add( new Vertex( p1, n, u, new Vector4( 1, 0, 0, 0 ) ) );
vb.Add( new Vertex( p2, n, u, new Vector4( 1, 1, 0, 0 ) ) );
vb.Add( new Vertex( p3, n, u, new Vector4( 0, 1, 0, 0 ) ) );
vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 1 ); vb.AddRawIndex( index + 2 );
vb.AddRawIndex( index + 0 ); vb.AddRawIndex( index + 2 ); vb.AddRawIndex( index + 3 );
index += 4;
}
}
private static float Project( Vector3 size, Vector3 axis )
=> MathF.Abs( axis.x ) * size.x + MathF.Abs( axis.y ) * size.y + MathF.Abs( axis.z ) * size.z;
protected override void OnDestroy()
{
gunObject?.Destroy();
gunObject = null;
MiningDrone.MatchPopulation( Scene, 0 );
}
}