VrcharacterController.cs
using Sandbox;
using Sandbox.Citizen;
using Sandbox.VR;
using System;
using static Sandbox.Gizmo;
using static Sandbox.VertexLayout;
public sealed class VrCharacterController : Component
{
[RequireComponent] VRAnchor VRAnchor { get; set; }
[Property] CameraComponent Camera { get; set; }
private PlayerCharacterController PlayerCharacterController { get; set; }
// how far the head is allowed to drift before we shift the world
[Property] public float RoomScaleLimit { get; set; } = 0.4f;
/// <summary>Eye height in units when standing upright (5'9" avatar ≈ 64).</summary>
[Property] public float StandingEyeHeight { get; set; } = 64f;
/// <summary>Eye height in units at which duck level reaches 1 (fully crouched).</summary>
[Property] public float CrouchedEyeHeight { get; set; } = 34f;
/// <summary>When false, headset tracking still runs but locomotion/turning/duck are skipped (join calibration).</summary>
[Property] public bool MovementEnabled { get; set; } = true;
private Scoreboard Scoreboard { get; set; }
private KillFeed KillFeed { get; set; }
protected override void OnStart()
{
if ( GetComponentInParent<VrCalibrationRig>().IsValid() )
MovementEnabled = false;
if ( !MovementEnabled )
return;
PlayerCharacterController = GetComponentInParent<PlayerCharacterController>();
PlayerCharacterController.Camera = Camera;
Scoreboard = PlayerCharacterController.GetComponentInChildren<Scoreboard>();
KillFeed = PlayerCharacterController.GetComponentInChildren<KillFeed>();
if ( !IsProxy && PlayerSession.CurrentSession.IsValid() )
{
StandingEyeHeight = PlayerSession.CurrentSession.StandingEyeHeight;
var scale = StandingEyeHeight / PlayerCharacterController.ReferenceStandingEyeHeight;
CrouchedEyeHeight = 34f * scale;
// Reserved spawn was only for calibration → first pawn; later respawns pick randomly.
PlayerSession.CurrentSession.HasReservedSpawn = false;
}
else if ( PlayerCharacterController.IsValid() )
{
CrouchedEyeHeight = 34f * PlayerCharacterController.HeightScale;
}
}
protected override void OnUpdate()
{
if ( !MovementEnabled )
return;
if ( !NetworkManager.Instance.IsMapReady )
{
PlayerCharacterController.Velocity = Vector3.Zero;
PlayerCharacterController.SyncedVelocity = Vector3.Zero;
return;
}
UpdateScoreboard();
HandleVRMovement();
UpdateAnimationHandsPlacement();
}
private void HandleVRMovement()
{
// Head reads below go through Input.VR.Head, which resolves against Input.VR.Anchor.
// Refresh the anchor before reading and again after every step that moves the rig.
RefreshVrAnchor();
ApplyRoomScale();
HandleVRTurning();
RefreshVrAnchor(); // turning rotated the rig about the head
UpdateDuckLevel();
CalculateVelocity();
PlayerCharacterController.Move();
PlayerCharacterController.UpdateFallAndBoundsDamage();
RefreshVrAnchor(); // Move() translated the rig
}
/// <summary>This frame's headset pose in world space. Valid only while Input.VR.Anchor is current.</summary>
private Transform Head => Input.VR.Head;
/// <summary>
/// Every Input.VR pose (hands, head, VRHand joints, VRTrackedObject) is resolved lazily as
/// Input.VR.Anchor.ToWorld(devicePose), and that global is only written by the engine VRAnchor
/// component's OnUpdate/OnPreRender. s&box dispatches OnUpdate/OnPreRender from flat component
/// sets with no ordering guarantee, so VRAnchor usually runs before this component moves the
/// player root and the anchor then points at last frame's rig pose until VRAnchor.OnPreRender
/// happens to run. Any consumer enumerated before it (held weapon GrabPoint, hand meshes, camera)
/// renders one locomotion step behind. Writing the anchor right after the rig moves removes
/// the stale window for every consumer regardless of component order.
/// </summary>
private void RefreshVrAnchor()
{
Input.VR.Anchor = VRAnchor.WorldTransform;
}
private void UpdateScoreboard()
{
Scoreboard.Enabled = Input.VR.LeftHand.ButtonA.IsPressed || DeathmatchGame.Instance.State == MatchState.ENDED;
KillFeed.Enabled = !Scoreboard.Enabled;
}
private void UpdateDuckLevel()
{
float headHeight = Head.Position.z - GameObject.Parent.WorldPosition.z;
float duck = MathX.Remap( headHeight, CrouchedEyeHeight, StandingEyeHeight, 1f, 0f );
PlayerCharacterController.SetDuckLevel( duck );
}
private void UpdateAnimationHandsPlacement()
{
PlayerCharacterController.AnimationLeftHand.WorldPosition = Input.VR.LeftHand.Transform.Position;
PlayerCharacterController.AnimationLeftHand.WorldRotation = Input.VR.LeftHand.Transform.Rotation;
PlayerCharacterController.AnimationRightHand.WorldPosition = Input.VR.RightHand.Transform.Position;
PlayerCharacterController.AnimationRightHand.WorldRotation = Input.VR.RightHand.Transform.Rotation;
}
private void ApplyRoomScale()
{
// 1. Get this frame's headset position relative to the Player Root.
// Input.VR.Head is used instead of the camera GameObject so we react to the current
// tracking sample rather than the pose the camera was given last PreRender.
Vector3 headWorldOffset = Head.Position - GameObject.Parent.WorldPosition;
// 2. Isolate the horizontal floor movement (ignore head bobbing up/down)
Vector3 flatOffset = new( headWorldOffset.x, headWorldOffset.y, 0 );
// 3. If the player has walked away from the center of the capsule
if ( flatOffset.Length > 0.01f )
{
// Move the entire physics capsule to sit right under the player's head
GameObject.Parent.WorldPosition += flatOffset;
// Counter-shift the VR rig backward by the exact same amount.
// This keeps the virtual camera perfectly still in world space
// while the underlying physics capsule catches up underneath them.
VRAnchor.WorldPosition -= flatOffset;
}
}
private void CalculateVelocity()
{
var input = Input.VR.LeftHand.Joystick.Value;
// use head yaw only
var yaw = Head.Rotation.Angles().yaw;
var rot = Rotation.FromYaw( yaw );
var moveDir =
(rot.Forward * input.y) +
(rot.Left * -input.x);
if ( moveDir.Length > 1f )
moveDir = moveDir.Normal;
if ( PlayerCharacterController.IsGrappling )
{
var wishVel = moveDir * PlayerCharacterController.MoveSpeed;
if ( Input.VR.RightHand.ButtonA.IsPressed && PlayerCharacterController.TryJump( wishVel.WithZ( 0 ) ) )
{
PlayerCharacterController.SyncedVelocity = PlayerCharacterController.Velocity;
return;
}
PlayerCharacterController.ApplyGrappleVelocity();
return;
}
var vel = PlayerCharacterController.Velocity;
vel.x = moveDir.x * PlayerCharacterController.MoveSpeed;
vel.y = moveDir.y * PlayerCharacterController.MoveSpeed;
if ( Input.VR.RightHand.ButtonA.IsPressed && PlayerCharacterController.TryJump( vel.WithZ( 0 ) ) )
{
vel = PlayerCharacterController.Velocity;
}
else if ( PlayerCharacterController.IsOnGround )
{
vel.z = 0;
PlayerCharacterController.Velocity = vel;
}
else
{
vel.z -= PlayerCharacterController.Gravity * Time.Delta;
vel.z = MathF.Max( vel.z, -PlayerCharacterController.TerminalVelocity );
PlayerCharacterController.Velocity = vel;
}
PlayerCharacterController.SyncedVelocity = PlayerCharacterController.Velocity;
}
private void HandleVRTurning()
{
// Get the horizontal input from the right joystick (standard for turning)
float turnInput = Input.VR.RightHand.Joystick.Value.x;
// Deadzone check to prevent drift
if ( MathF.Abs( turnInput ) < 0.1f )
return;
// Calculate how much we want to rotate this frame
float yawDelta = -turnInput * PlayerCharacterController.TurnSpeed * Time.Delta;
Rotation turnRotation = Rotation.FromYaw( yawDelta );
// ---- THE VR TURNING TRICK ----
// We must rotate around the player's actual head position, not the root center.
Vector3 headWorldPos = Head.Position;
// 1. Rotate the root's orientation
GameObject.Parent.WorldRotation = turnRotation * GameObject.Parent.WorldRotation;
// 2. Pivot the root's position around the head
// This stops the "swinging ride" effect if they are standing far from the center of their room.
Vector3 offsetFromHead = GameObject.Parent.WorldPosition - headWorldPos;
Vector3 rotatedOffset = turnRotation * offsetFromHead;
// Adjust world position so the head stays exactly where it was before the rotation
GameObject.Parent.WorldPosition = headWorldPos + rotatedOffset;
}
}