PlayerState.cs
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
public enum PreMatchPlayerState
{
Lobby,
Warmup,
Ready
}
public sealed class PlayerState : Component
{
[Property, Sync( SyncFlags.FromHost )]
public int TeamId { get; set; } = -1;
[Property, Sync( SyncFlags.FromHost )]
public bool IsAlive { get; set; } = true;
[Property, Sync( SyncFlags.FromHost )]
public bool IsReady { get; set; } = false;
/// <summary>
/// Individual state while both players are connected to the
/// online pre-match lobby.
///
/// This is deliberately separate from IsReady. IsReady means
/// the networked PlayerPrefab has initialized; PreMatchState
/// means what that player is currently doing in the lobby.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public PreMatchPlayerState PreMatchState { get; set; } =
PreMatchPlayerState.Lobby;
/// <summary>
/// Player-local warmup weapon choice, using RoundManager's
/// SandboxWeapons catalog as the shared weapon list.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public int WarmupWeaponIndex { get; set; } = 0;
// =========================================================
// PLAYER IDENTITY / BOT FOUNDATION
// =========================================================
/// <summary>
/// True when this player prefab is controlled by OMR bot logic instead of a
/// human Connection. Bots remain normal player prefabs for health, inventory,
/// locomotion, round rules and replication.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public bool IsBot { get; set; } = false;
/// <summary>
/// Network-visible identity used instead of the host connection name for a
/// host-controlled bot. Human players continue to resolve their real display
/// name from the owning connection.
/// </summary>
[Property, Sync( SyncFlags.FromHost )]
public string BotDisplayName { get; set; } = "ROOK";
public bool IsLocalHuman =>
!IsProxy &&
!IsBot;
// Compatibility name retained for existing gameplay/UI callers. A host-owned
// bot is not the local human even though the engine correctly reports it as
// non-proxy on the host.
public bool IsLocalPlayer =>
IsLocalHuman;
public string DisplayName
{
get
{
if ( IsBot )
{
return string.IsNullOrWhiteSpace( BotDisplayName )
? "BOT"
: BotDisplayName;
}
Connection owner =
GameObject?.Network.Owner;
if (
owner is not null &&
!string.IsNullOrWhiteSpace( owner.DisplayName )
)
{
return owner.DisplayName;
}
return TeamId == 0
? "PLAYER A"
: TeamId == 1
? "PLAYER B"
: "PLAYER";
}
}
// =========================================================
// ROUND SPAWN STABILIZATION
// =========================================================
/// <summary>
/// Short owner-side settling window after a round teleport. During this
/// time controls are already locked by RoundManager, so OMR can safely
/// drain stale MoveMode smoothing and clear residual rigidbody/ground
/// velocity before the countdown releases the player.
/// </summary>
[Property, Group( "Round Spawn Stabilization" ), Range( 0f, 0.5f )]
public float SpawnStabilizationDuration { get; set; } = 0.20f;
/// <summary>
/// Horizontal drift smaller than this is left alone so normal grounding
/// can settle vertically without OMR fighting the PlayerController.
/// </summary>
[Property, Group( "Round Spawn Stabilization" ), Range( 0f, 4f )]
public float SpawnHorizontalDriftTolerance { get; set; } = 0.5f;
/// <summary>
/// Temporary diagnostics for the intermittent round-spawn launch bug.
/// Disable after the spawn path has been validated in longer sessions.
/// </summary>
[Property, Group( "Round Spawn Stabilization" )]
public bool LogSpawnStabilization { get; set; } = true;
private bool _spawnStabilizationActive;
private Vector3 _spawnStabilizationPosition;
private float _spawnStabilizationRemaining;
private int _spawnStabilizationTicks;
private int _spawnStabilizationCorrections;
private float _spawnMaxHorizontalDrift;
private float _spawnMaxBodySpeed;
private float _spawnMaxGroundSpeed;
private float _spawnMaxSmoothedMoveSpeed;
// =========================================================
// MODULAR ROUND RULE LOCAL CONTROL
// =========================================================
private bool _roundRuleLocalActive;
private OMRRoundRuleProfile _roundRuleLocalProfile;
private OneMoreRoundWeapon _roundRuleWeapon;
private readonly HashSet<string> _roundRuleWeaponModifierIds =
new( StringComparer.OrdinalIgnoreCase );
private float _defaultGravityScale = 1f;
private bool _defaultGravityScaleCaptured;
// =========================================================
// HIGH NOON LOCAL CONTROL
// =========================================================
private bool _highNoonLocalActive;
private bool _highNoonDrawSequenceActive;
private bool _highNoonTriggerReported;
private int _highNoonRoundNumber = -1;
private float _highNoonDrawAt;
private Vector3 _highNoonLookTarget;
private Angles _highNoonLockedEyeAngles;
private OneMoreRoundWeapon _highNoonWeapon;
private bool _highNoonWeaponRulesConfigured;
protected override void OnUpdate()
{
if ( IsProxy )
return;
if ( _roundRuleLocalActive )
{
ApplyRoundRuleGravity();
EnsureRoundRuleWeaponModifiers();
}
if ( _highNoonLocalActive )
{
ApplyHighNoonLookLock();
EnsureHighNoonWeaponRules();
}
}
protected override void OnStart()
{
Log.Info(
$"PlayerState started | Owner: {Network.OwnerId} | LocalHuman: {IsLocalHuman} | Bot:{IsBot}"
);
if ( IsProxy )
return;
// A player avatar belongs to exactly one connection. If that
// connection leaves the current lobby, destroy the network object
// instead of orphaning it locally. This prevents a stale local
// player/viewmodel from surviving a disconnect and overlapping the
// replacement player spawned by NetworkHelper in a new session.
GameObject.RootNetwork.SetOrphanedMode(
NetworkOrphaned.Destroy
);
CaptureDefaultGravityScale();
ApplyMenuControls(
true
);
ReportReady();
}
protected override void OnFixedUpdate()
{
if (
IsProxy ||
!_spawnStabilizationActive
)
return;
var movementInput =
Components.Get<PlayerMovementInput>();
// Safety first: this stabilizer exists only inside the round lock.
// Never keep clamping a player after gameplay controls are released.
if (
movementInput is not null &&
movementInput.ControlsEnabled
)
{
FinishSpawnStabilization(
"CONTROLS ENABLED"
);
return;
}
var controller =
Components.Get<PlayerController>();
if ( controller is null )
{
FinishSpawnStabilization(
"NO CONTROLLER"
);
return;
}
StabilizeRoundSpawnTick(
controller
);
}
[Rpc.Host]
private void ReportReady()
{
if ( !Networking.IsHost )
return;
var health =
Components.Get<PlayerHealth>();
if ( health is not null )
{
health.ResetHealth();
}
IsAlive =
true;
IsReady =
true;
PreMatchState =
PreMatchPlayerState.Lobby;
Log.Info(
$"PLAYER READY | Owner: {Network.OwnerId} | Team: {TeamId}"
);
}
// =========================================================
// ROUND SPAWN / OWNER-SIDE RESET
// =========================================================
/// <summary>
/// Moves the owning player to the round spawn and resets
/// owner-local transient movement/weapon state.
///
/// Health, reload cancellation and ammunition are reset by
/// RoundManager on the authoritative host.
/// </summary>
[Rpc.Owner]
public void MoveToRoundSpawn(
Vector3 position,
Vector3 lookTarget
)
{
var movementInput =
Components.Get<PlayerMovementInput>();
movementInput?.SetControlsEnabled(
false
);
var sprint =
Components.Get<PlayerSprint>();
var slide =
Components.Get<PlayerSlide>();
var crouch =
Components.Get<PlayerCrouch>();
sprint?.ResetSprint();
slide?.ResetSlide();
if (
crouch is not null &&
crouch.Enabled
)
{
crouch.ResetToStanding();
}
ResetLocalWeaponState();
Components.Get<PlayerCombatFeedback>()?.ResetTransient();
WorldPosition =
position;
Network.ClearInterpolation();
var controller =
Components.Get<PlayerController>();
if ( controller is null )
return;
Vector3 previousWishVelocity =
controller.WishVelocity;
Vector3 previousGroundVelocity =
controller.GroundVelocity;
Vector3 previousBodyVelocity =
controller.Body is not null
? controller.Body.Velocity
: Vector3.Zero;
ZeroControllerMotion(
controller
);
Vector3 lookDirection =
(
lookTarget -
position
)
.WithZ(
0f
);
if (
!lookDirection.IsNearlyZero(
0.001f
)
)
{
Angles facingAngles =
Rotation.LookAt(
lookDirection.Normal,
Vector3.Up
)
.Angles();
controller.EyeAngles =
new Angles(
0f,
facingAngles.yaw,
0f
);
}
BeginSpawnStabilization(
controller,
position,
previousWishVelocity,
previousGroundVelocity,
previousBodyVelocity
);
}
private void BeginSpawnStabilization(
PlayerController controller,
Vector3 position,
Vector3 previousWishVelocity,
Vector3 previousGroundVelocity,
Vector3 previousBodyVelocity
)
{
_spawnStabilizationPosition =
position;
_spawnStabilizationRemaining =
MathF.Max(
SpawnStabilizationDuration,
0f
);
_spawnStabilizationTicks =
0;
_spawnStabilizationCorrections =
0;
_spawnMaxHorizontalDrift =
0f;
_spawnMaxBodySpeed =
previousBodyVelocity.Length;
_spawnMaxGroundSpeed =
previousGroundVelocity.Length;
_spawnMaxSmoothedMoveSpeed =
0f;
_spawnStabilizationActive =
_spawnStabilizationRemaining > 0f;
if ( LogSpawnStabilization )
{
Log.Info(
$"SPAWN STABILIZE BEGIN | Team:{TeamId} | Target:{position} | Duration:{_spawnStabilizationRemaining:0.###} | WishBefore:{previousWishVelocity} | GroundBefore:{previousGroundVelocity} | BodyBefore:{previousBodyVelocity} | Mode:{controller.Mode?.GetType().Name ?? "NONE"}"
);
}
}
private void StabilizeRoundSpawnTick(
PlayerController controller
)
{
_spawnStabilizationTicks++;
Vector3 positionBefore =
WorldPosition;
Vector3 drift =
positionBefore -
_spawnStabilizationPosition;
Vector3 horizontalDrift =
drift.WithZ(
0f
);
float horizontalDistance =
horizontalDrift.Length;
float bodySpeed =
controller.Body is not null
? controller.Body.Velocity.Length
: 0f;
float groundSpeed =
controller.GroundVelocity.Length;
if (
horizontalDistance >
_spawnMaxHorizontalDrift
)
{
_spawnMaxHorizontalDrift =
horizontalDistance;
}
if ( bodySpeed > _spawnMaxBodySpeed )
{
_spawnMaxBodySpeed =
bodySpeed;
}
if ( groundSpeed > _spawnMaxGroundSpeed )
{
_spawnMaxGroundSpeed =
groundSpeed;
}
// MoveMode keeps its own private movement smoothing. OMR normally stops
// calling UpdateMove while round controls are locked, which means the
// last pre-reset movement vector can survive until the fight is enabled
// again. Feed zero input during the settling window to drain that state,
// but deliberately discard the returned velocity.
float smoothedMoveSpeed =
0f;
if ( controller.Mode is not null )
{
Vector3 smoothedMove =
controller.Mode.UpdateMove(
controller.EyeAngles.ToRotation(),
Vector3.Zero
);
smoothedMoveSpeed =
smoothedMove.WithZ( 0f ).Length;
if (
smoothedMoveSpeed >
_spawnMaxSmoothedMoveSpeed
)
{
_spawnMaxSmoothedMoveSpeed =
smoothedMoveSpeed;
}
}
ZeroControllerMotion(
controller
);
bool corrected =
horizontalDistance >
MathF.Max(
SpawnHorizontalDriftTolerance,
0f
);
if ( corrected )
{
// Preserve vertical settling. PlayerController owns grounding and may
// legitimately move a fraction vertically while its capsule settles.
WorldPosition =
positionBefore -
horizontalDrift;
Network.ClearInterpolation();
_spawnStabilizationCorrections++;
if ( LogSpawnStabilization )
{
Log.Warning(
$"SPAWN STABILIZE CORRECT | Team:{TeamId} | Tick:{_spawnStabilizationTicks} | HorizontalDrift:{horizontalDistance:0.###} | VerticalDrift:{drift.z:0.###} | BodyVel:{bodySpeed:0.###} | GroundVel:{groundSpeed:0.###} | SmoothedMove:{smoothedMoveSpeed:0.###} | Mode:{controller.Mode?.GetType().Name ?? "NONE"}"
);
}
}
_spawnStabilizationRemaining -=
Time.Delta;
if ( _spawnStabilizationRemaining <= 0f )
{
FinishSpawnStabilization(
"SETTLED"
);
}
}
private static void ZeroControllerMotion(
PlayerController controller
)
{
if ( controller is null )
return;
controller.WishVelocity =
Vector3.Zero;
controller.GroundVelocity =
Vector3.Zero;
if ( controller.Body is null )
return;
controller.Body.Velocity =
Vector3.Zero;
controller.Body.AngularVelocity =
Vector3.Zero;
controller.Body.ClearForces();
}
private void FinishSpawnStabilization(
string reason
)
{
if ( !_spawnStabilizationActive )
return;
_spawnStabilizationActive =
false;
if ( !LogSpawnStabilization )
return;
var controller =
Components.Get<PlayerController>();
Vector3 finalDrift =
WorldPosition -
_spawnStabilizationPosition;
float finalBodySpeed =
controller is not null &&
controller.Body is not null
? controller.Body.Velocity.Length
: 0f;
Log.Info(
$"SPAWN STABILIZE END | Team:{TeamId} | Reason:{reason} | Ticks:{_spawnStabilizationTicks} | Corrections:{_spawnStabilizationCorrections} | MaxHorizontalDrift:{_spawnMaxHorizontalDrift:0.###} | MaxBodySpeed:{_spawnMaxBodySpeed:0.###} | MaxGroundSpeed:{_spawnMaxGroundSpeed:0.###} | MaxSmoothedMove:{_spawnMaxSmoothedMoveSpeed:0.###} | FinalHorizontalDrift:{finalDrift.WithZ( 0f ).Length:0.###} | FinalVerticalDrift:{finalDrift.z:0.###} | FinalBodySpeed:{finalBodySpeed:0.###}"
);
}
/// <summary>
/// Resets only owner-local custom weapon state.
///
/// We deliberately do not call BaseCombatWeapon.CreateViewModel
/// or DestroyViewModel here: those methods are not public from
/// unrelated components in the current engine API.
/// </summary>
private void ResetLocalWeaponState()
{
BaseInventoryComponent inventory =
Components.Get<BaseInventoryComponent>();
if ( inventory is null )
return;
if (
inventory.ActiveItem is
OneMoreRoundWeapon weapon
)
{
weapon.ResetForRoundPresentation();
}
}
// =========================================================
// MODULAR ROUND RULE RUNTIME
// =========================================================
/// <summary>
/// Applies owner-simulated pieces of one authored round rule. Gravity and
/// weapon stat modifiers must exist on the peer that actually simulates the
/// PlayerController/weapon input; the host separately applies authoritative
/// damage rules and its local weapon stat snapshot.
/// </summary>
[Rpc.Owner]
public void ApplyRoundRule(
RoundModifier modifier
)
{
ClearRoundRuleWeaponModifiers();
_roundRuleLocalActive = true;
_roundRuleLocalProfile =
FindRoundRuleDirector()?.
ResolveRule( modifier );
CaptureDefaultGravityScale();
ApplyRoundRuleGravity();
ApplyRoundRuleMovement();
EnsureRoundRuleWeaponModifiers();
}
[Rpc.Owner]
public void ClearRoundRuleState()
{
ClearRoundRuleLocalState();
}
private void CaptureDefaultGravityScale()
{
if ( _defaultGravityScaleCaptured )
return;
PlayerController controller =
Components.Get<PlayerController>();
if (
controller?.Body is null
)
return;
_defaultGravityScale =
MathF.Max(
controller.Body.GravityScale,
0.01f
);
_defaultGravityScaleCaptured = true;
}
private void ApplyRoundRuleGravity()
{
PlayerController controller =
Components.Get<PlayerController>();
if ( controller?.Body is null )
return;
float requestedScale =
_roundRuleLocalProfile is not null &&
_roundRuleLocalProfile.IsValid
? _roundRuleLocalProfile.GravityScale
: _defaultGravityScale;
requestedScale =
MathX.Clamp(
requestedScale,
0.05f,
3f
);
if (
MathF.Abs(
controller.Body.GravityScale -
requestedScale
) > 0.001f
)
{
controller.Body.GravityScale =
requestedScale;
}
}
private void ApplyRoundRuleMovement()
{
PlayerLocomotion locomotion =
Components.Get<PlayerLocomotion>();
int additionalAirJumps =
_roundRuleLocalProfile is not null &&
_roundRuleLocalProfile.IsValid
? Math.Clamp(
_roundRuleLocalProfile.AdditionalAirJumps,
0,
3
)
: 0;
locomotion?.SetRoundAdditionalAirJumps(
additionalAirJumps
);
}
private void EnsureRoundRuleWeaponModifiers()
{
BaseInventoryComponent inventory =
Components.Get<BaseInventoryComponent>();
OneMoreRoundWeapon activeWeapon =
inventory?.ActiveItem as
OneMoreRoundWeapon;
if ( _roundRuleWeapon != activeWeapon )
{
ClearRoundRuleWeaponModifiers();
_roundRuleWeapon = activeWeapon;
}
if ( activeWeapon is not null )
{
bool allowAim =
_roundRuleLocalProfile is null ||
!_roundRuleLocalProfile.IsValid ||
_roundRuleLocalProfile.AllowAim;
activeWeapon.SetRoundAimAllowed(
allowAim
);
}
if (
activeWeapon is null ||
_roundRuleLocalProfile is null ||
!_roundRuleLocalProfile.IsValid ||
_roundRuleLocalProfile.WeaponModifiers is null
)
return;
foreach (
OMRWeaponModifierDefinition modifier in
_roundRuleLocalProfile.WeaponModifiers
)
{
if (
modifier is null ||
!modifier.IsValid
)
continue;
string id =
modifier.ModifierId?.Trim() ??
string.Empty;
if (
string.IsNullOrWhiteSpace( id ) ||
_roundRuleWeaponModifierIds.Contains( id )
)
continue;
if ( activeWeapon.SetStatModifier( modifier ) )
{
_roundRuleWeaponModifierIds.Add( id );
}
}
}
private void ClearRoundRuleWeaponModifiers()
{
if (
_roundRuleWeapon is not null &&
_roundRuleWeapon.IsValid
)
{
_roundRuleWeapon.SetRoundAimAllowed(
true
);
foreach ( string id in _roundRuleWeaponModifierIds )
{
_roundRuleWeapon.RemoveStatModifier( id );
}
}
_roundRuleWeaponModifierIds.Clear();
_roundRuleWeapon = null;
}
private void ClearRoundRuleLocalState()
{
ClearRoundRuleWeaponModifiers();
_roundRuleLocalActive = false;
_roundRuleLocalProfile = null;
Components
.Get<PlayerLocomotion>()?
.SetRoundAdditionalAirJumps(
0
);
CaptureDefaultGravityScale();
PlayerController controller =
Components.Get<PlayerController>();
if ( controller?.Body is not null )
{
controller.Body.GravityScale =
_defaultGravityScaleCaptured
? _defaultGravityScale
: 1f;
}
}
private OMRRoundRuleDirector FindRoundRuleDirector()
{
return Scene?
.GetAllComponents<OMRRoundRuleDirector>()
.FirstOrDefault();
}
// =========================================================
// HIGH NOON CONTROL STATE
// =========================================================
/// <summary>
/// Locks movement, look and weapon use during the normal round countdown.
/// The owner keeps this entirely local because its PlayerController and
/// inventory input are owner-simulated.
/// </summary>
[Rpc.Owner]
public void LockHighNoonCountdown(
Vector3 lookTarget
)
{
ApplyHighNoonLock(
lookTarget,
drawSequenceActive: false,
drawAt: 0f,
roundNumber: -1
);
}
/// <summary>
/// Begins the latency-aware High Noon draw sequence.
///
/// Weapon input is pumped immediately so its local gate can observe trigger
/// release state, but CanPrimaryAttack remains blocked until the synchronized
/// drawAt timestamp and requires a fresh post-DRAW press.
/// </summary>
[Rpc.Owner]
public void BeginHighNoonDrawSequence(
Vector3 lookTarget,
float drawAt,
int roundNumber
)
{
ApplyHighNoonLock(
lookTarget,
drawSequenceActive: true,
drawAt: drawAt,
roundNumber: roundNumber
);
}
[Rpc.Owner]
public void ClearHighNoonRoundState()
{
ClearHighNoonLocalState(
restoreLook: true
);
}
private void ApplyHighNoonLock(
Vector3 lookTarget,
bool drawSequenceActive,
float drawAt,
int roundNumber
)
{
_highNoonLocalActive = true;
_highNoonDrawSequenceActive = drawSequenceActive;
_highNoonTriggerReported = false;
_highNoonRoundNumber = roundNumber;
_highNoonDrawAt = drawAt;
_highNoonLookTarget = lookTarget;
_highNoonWeaponRulesConfigured = false;
var controller =
Components.Get<PlayerController>();
if ( controller is not null )
{
Vector3 lookDirection =
(
lookTarget -
WorldPosition
)
.WithZ(
0f
);
if (
!lookDirection.IsNearlyZero(
0.001f
)
)
{
Angles facingAngles =
Rotation.LookAt(
lookDirection.Normal,
Vector3.Up
)
.Angles();
_highNoonLockedEyeAngles =
new Angles(
0f,
facingAngles.yaw,
0f
);
}
else
{
_highNoonLockedEyeAngles =
controller.EyeAngles;
}
controller.UseLookControls = false;
controller.UseInputControls = false;
ZeroControllerMotion(
controller
);
controller.EyeAngles =
_highNoonLockedEyeAngles;
}
var movementInput =
Components.Get<PlayerMovementInput>();
movementInput?.
SetControlsEnabled(
false
);
var inventory =
Components.Get<BaseInventoryComponent>();
if ( inventory is not null )
{
// During the countdown the whole weapon input path remains paused.
// Once the draw sequence begins we pump the weapon so its temporary
// fire gate can track trigger release and open locally at drawAt.
inventory.ManualPumping =
!drawSequenceActive;
}
EnsureHighNoonWeaponRules();
}
private void ApplyHighNoonLookLock()
{
var controller =
Components.Get<PlayerController>();
if ( controller is null )
return;
controller.UseLookControls = false;
controller.UseInputControls = false;
controller.WishVelocity = Vector3.Zero;
if ( controller.Body is not null )
{
controller.Body.Velocity = Vector3.Zero;
controller.Body.AngularVelocity = Vector3.Zero;
controller.Body.ClearForces();
}
controller.EyeAngles =
_highNoonLockedEyeAngles;
}
private void EnsureHighNoonWeaponRules()
{
var inventory =
Components.Get<BaseInventoryComponent>();
OneMoreRoundWeapon activeWeapon =
inventory?.ActiveItem as
OneMoreRoundWeapon;
if (
_highNoonWeapon is not null &&
_highNoonWeapon != activeWeapon
)
{
_highNoonWeapon.ClearTemporaryRoundRules();
_highNoonWeapon = null;
_highNoonWeaponRulesConfigured = false;
}
if ( activeWeapon is null )
return;
_highNoonWeapon =
activeWeapon;
if ( _highNoonWeaponRulesConfigured )
return;
activeWeapon.SetTemporarySpreadOverride(
Vector2.Zero
);
if ( _highNoonDrawSequenceActive )
{
activeWeapon.ConfigureTemporaryPrimaryGate(
_highNoonDrawAt,
requireRelease: true
);
}
_highNoonWeaponRulesConfigured = true;
}
private void ClearHighNoonLocalState(
bool restoreLook
)
{
_highNoonWeapon?.
ClearTemporaryRoundRules();
_highNoonWeapon = null;
_highNoonWeaponRulesConfigured = false;
_highNoonLocalActive = false;
_highNoonDrawSequenceActive = false;
_highNoonTriggerReported = false;
_highNoonRoundNumber = -1;
_highNoonDrawAt = 0f;
_highNoonLookTarget = Vector3.Zero;
if ( !restoreLook )
return;
var controller =
Components.Get<PlayerController>();
if ( controller is not null )
{
controller.UseLookControls = true;
}
}
/// <summary>
/// Called by OneMoreRoundWeapon only after a valid PrimaryAttack actually
/// commits. Time.Now is synchronized across peers, so this timestamp can be
/// compared on the host without using RPC arrival order as the reaction time.
/// </summary>
public void NotifyRoundPrimaryShotFired(
float firedAt
)
{
if (
!_highNoonLocalActive ||
!_highNoonDrawSequenceActive ||
_highNoonTriggerReported ||
_highNoonRoundNumber < 0
)
return;
if (
firedAt + 0.001f <
_highNoonDrawAt
)
return;
_highNoonTriggerReported = true;
RoundManager roundManager =
FindRoundManager();
if ( Networking.IsHost )
{
roundManager?.
HandleHighNoonTrigger(
this,
_highNoonRoundNumber,
firedAt
);
return;
}
ReportHighNoonTriggerHost(
_highNoonRoundNumber,
firedAt
);
}
[Rpc.Host]
private void ReportHighNoonTriggerHost(
int roundNumber,
float firedAt
)
{
if ( !Networking.IsHost )
return;
// The RPC lives on the owned player object. Reject a caller trying to
// submit reaction data through somebody else's PlayerState.
if (
Rpc.CallerId !=
Network.OwnerId
)
return;
FindRoundManager()?.
HandleHighNoonTrigger(
this,
roundNumber,
firedAt
);
}
private RoundManager FindRoundManager()
{
return Scene?
.GetAllComponents<RoundManager>()
.FirstOrDefault();
}
// =========================================================
// CONTROL STATE
// =========================================================
[Rpc.Owner]
public void SetRoundControls(
bool enabled
)
{
ApplyRoundControls(
enabled
);
}
[Rpc.Owner]
public void SetMenuControls(
bool menuOpen
)
{
ApplyMenuControls(
menuOpen
);
}
private void ApplyMenuControls(
bool menuOpen
)
{
if ( menuOpen )
{
ClearHighNoonLocalState(
restoreLook: false
);
ClearRoundRuleLocalState();
}
var controller =
Components.Get<PlayerController>();
if ( controller is not null )
{
controller.UseLookControls =
!menuOpen;
//
// Built-in movement input stays disabled.
//
controller.UseInputControls =
false;
}
if ( menuOpen )
{
ApplyRoundControls(
false
);
}
}
private void ApplyRoundControls(
bool enabled
)
{
if ( _highNoonLocalActive )
{
ClearHighNoonLocalState(
restoreLook: true
);
}
if (
enabled &&
_spawnStabilizationActive
)
{
FinishSpawnStabilization(
"ROUND CONTROLS ENABLED"
);
}
var controller =
Components.Get<PlayerController>();
var movementInput =
Components.Get<PlayerMovementInput>();
var inventory =
Components.Get<BaseInventoryComponent>();
if ( controller is not null )
{
controller.UseInputControls =
false;
}
if (
movementInput is not null
)
{
movementInput.SetControlsEnabled(
enabled
);
}
if ( !enabled )
{
if ( controller is not null )
{
controller.WishVelocity =
Vector3.Zero;
if (
controller.Body is not null
)
{
controller.Body.Velocity =
Vector3.Zero;
controller.Body.AngularVelocity =
Vector3.Zero;
controller.Body.ClearForces();
}
}
}
if ( inventory is not null )
{
inventory.ManualPumping =
!enabled;
}
}
}