Partial RoleplayPlayer class holding the police state of a player: on duty, grade, badge, cuffs, escort, jail time and unit radio status, all synced from the host. Applies cuffed hand IK, removes the escortee's collider, positions an escorted player in front of the officer, strips movement and action input while restrained, and exposes host-to-owner RPCs for opening the police search menu, marking a citizen known, presenting an ID and receiving unpaid fines. Also adds PoliceInputSystem, a GameObjectSystem that runs the restraint input filter at the start of update and fixed update.
namespace HorizonRP;
/// <summary>
/// L'état « police » d'un joueur, vu de tous (écrit par l'hôte) : en service, menotté, escorté, en
/// prison. L'hôte décide (Police/PoliceActions.cs, PoliceService.Service.cs) ; chaque machine en
/// tire l'affichage (mains dans le dos), et celle du joueur ses commandes : un menotté marche
/// lentement mais ne fait rien d'autre (ni E, ni arme, ni inventaire, ni téléphone, ni menus), un
/// escorté suit l'agent, posé devant lui à chaque image par sa propre machine — c'est elle qui
/// simule son personnage : une position imposée par l'hôte serait écrasée aussitôt (comme
/// <see cref="Held"/> au physgun).
/// </summary>
public sealed partial class RoleplayPlayer
{
/// <summary>Policier en service (vestiaire) : seul cas où ses droits de police s'appliquent.</summary>
[Sync( SyncFlags.FromHost )] public bool OnDuty { get; set; }
/// <summary>Le grade de l'agent (PoliceRules.Grades), -1 s'il n'est pas inscrit.</summary>
[Sync( SyncFlags.FromHost )] public int PoliceGrade { get; set; } = -1;
[Sync( SyncFlags.FromHost )] public string PoliceBadge { get; set; }
[Sync( SyncFlags.FromHost )] public string PoliceCallsign { get; set; }
/// <summary>Menotté : mains dans le dos, il marche mais ne fait plus rien d'autre.</summary>
[Sync( SyncFlags.FromHost )] public bool Cuffed { get; set; }
/// <summary>L'agent qui l'escorte (son objet), ou vide.</summary>
[Sync( SyncFlags.FromHost )] public Guid EscortedBy { get; set; }
/// <summary>Le temps de prison restant (secondes de jeu en ligne), 0 s'il est libre.</summary>
[Sync( SyncFlags.FromHost )] public int JailSecondsLeft { get; set; }
/// <summary>La peine entière (secondes), pour la barre du minuteur de prison.</summary>
[Sync( SyncFlags.FromHost )] public int JailSecondsTotal { get; set; }
/// <summary>
/// Le statut radio de l'unité (PoliceService.UnitStates : disponible, en_route, sur_place,
/// occupe), choisi sur l'ordinateur de bord ou la tablette et vu de tous les agents (accueil du
/// MDT, appels 911). Écrit par l'hôte (PoliceService.RequestUnitStatus, prise et clôture d'un
/// appel, arrivée sur les lieux) ; vidé hors service.
/// </summary>
[Sync( SyncFlags.FromHost )] public string UnitStatus { get; set; }
/// <summary>Un policier en service.</summary>
public bool IsCop => OnDuty && JobId == PoliceRules.JobId && PoliceGrade >= 0;
/// <summary>En prison.</summary>
public bool Jailed => JailSecondsLeft > 0;
/// <summary>
/// Hôte : l'apparence civile, gardée pendant qu'une tenue de service est portée. C'est elle qui
/// est sauvegardée (ToProfile) : une tenue ne doit jamais remplacer les vêtements du personnage.
/// </summary>
public string CivilianAppearance { get; set; }
/// <summary>La marche d'un menotté (unités par seconde ; la marche normale est à 110).</summary>
public const float CuffedSpeed = 70f;
/// <summary>L'escorté, posé devant l'agent (pouces entre les deux centres, ~0,9 m : les deux capsules ne se touchent pas).</summary>
public const float EscortDistance = 36f;
// Les mains dans le dos, par cinématique inverse (à régler en jeu : des propriétés statiques,
// réglables à chaud par un script run_csharp).
public static float CuffBack { get; set; } = 7f;
public static float CuffSpread { get; set; } = 2.5f;
public static float CuffUp { get; set; } = 3f;
public static Angles CuffHandAngles { get; set; } = new( 0, 180, 90 );
/// <summary>Chaque image, sur chaque machine (RoleplayPlayer.OnUpdate).</summary>
void UpdatePolice()
{
UpdateCuffPose();
UpdateEscortColliders();
// Hors service, plus de statut radio : la prochaine prise de service repart « disponible ».
if ( Networking.IsHost && !IsCop && !string.IsNullOrEmpty( UnitStatus ) )
UnitStatus = null;
if ( Network.IsOwner )
UpdateCuffControls();
}
bool cuffPosed;
/// <summary>Toutes les machines : les deux mains derrière le bassin, poignets croisés.</summary>
void UpdateCuffPose()
{
var renderer = GetComponent<PlayerController>()?.Renderer;
if ( !renderer.IsValid() )
return;
var pose = Cuffed && !OnTheGround;
if ( !pose )
{
if ( cuffPosed )
{
renderer.ClearIk( "hand_left" );
renderer.ClearIk( "hand_right" );
cuffPosed = false;
}
return;
}
cuffPosed = true;
var rot = renderer.WorldRotation;
var pelvis = renderer.TryGetBoneTransform( "pelvis", out var t ) ? t.Position : renderer.WorldPosition + Vector3.Up * 38;
var a = CuffHandAngles;
foreach ( var (name, side) in new[] { ("hand_left", 1f), ("hand_right", -1f) } )
{
var at = pelvis + rot.Backward * CuffBack + rot.Left * (side * CuffSpread) + rot.Up * CuffUp;
renderer.SetIk( name, new Transform( at, rot * new Angles( a.pitch, a.yaw, a.roll * side ).ToRotation() ) );
}
}
bool escortCollidersOff;
/// <summary>
/// Toutes les machines : un escorté n'a plus de collision, sinon son corps pousserait l'agent qui
/// le mène (sur la machine de l'agent, qui simule l'agent). Assis, le siège s'en occupe.
/// </summary>
void UpdateEscortColliders()
{
var escorted = EscortedBy != Guid.Empty;
if ( escorted == escortCollidersOff )
return;
escortCollidersOff = escorted;
var controller = GetComponent<PlayerController>();
if ( !controller.IsValid() || !controller.ColliderObject.IsValid() || GameObject.GetComponentInParent<BaseChair>() is not null )
return;
controller.ColliderObject.Enabled = !escorted;
}
bool cuffControls, escortFollowing;
float? walkBeforeCuffs;
/// <summary>Chez le joueur : la marche lente, plus de E, et l'escorte qui le pose devant l'agent.</summary>
void UpdateCuffControls()
{
var controller = GetComponent<PlayerController>();
if ( !controller.IsValid() )
return;
if ( Cuffed != cuffControls )
{
cuffControls = Cuffed;
// Plus de E : ni portes, ni portières, ni descendre du véhicule (le mode « assis » lit un E
// manqué comme « descendre »), ni fouille.
controller.EnablePressing = !Cuffed;
if ( Cuffed )
walkBeforeCuffs = controller.WalkSpeed;
else if ( walkBeforeCuffs is { } walk )
controller.WalkSpeed = walk;
}
if ( Cuffed && controller.WalkSpeed != CuffedSpeed )
controller.WalkSpeed = CuffedSpeed;
var officer = EscortedBy == Guid.Empty ? null : Scene.Directory.FindByGuid( EscortedBy );
if ( officer.IsValid() && GameObject.GetComponentInParent<BaseChair>() is null )
{
var officerController = officer.GetComponent<PlayerController>();
var yaw = officerController?.Renderer is { } body ? body.WorldRotation.Yaw() : officerController?.EyeAngles.yaw ?? officer.WorldRotation.Yaw();
WorldPosition = officer.WorldPosition + Rotation.FromYaw( yaw ).Forward * EscortDistance;
controller.EyeAngles = controller.EyeAngles.WithYaw( yaw );
if ( controller.Body.IsValid() )
{
controller.Body.Velocity = Vector3.Zero;
controller.Body.Gravity = false;
}
escortFollowing = true;
}
else if ( escortFollowing )
{
escortFollowing = false;
if ( controller.Body.IsValid() )
controller.Body.Gravity = !Held;
}
}
/// <summary>Les actions coupées chez un menotté (relevées au début de chaque image, PoliceInputSystem).</summary>
static readonly string[] CuffedActions =
{
"use", "attack1", "attack2", "reload", "Jump", "Run", "Duck",
"Slot1", "Slot2", "Slot3", "Slot4", "Slot5", "SlotPrev", "SlotNext", "Drop",
"Phone", "Inventory", "Wallet", "JobMenu", "PropMenu", "PropUndo", "WeaponMenu", "DoorMenu",
"VehicleLock", "Flashlight", "PoliceMenu", "PoliceTablet",
};
static readonly string[] MoveActions = { "Forward", "Backward", "Left", "Right" };
/// <summary>La touche « pressée » pour refermer un menu cette image : l'image fixe ne doit pas l'effacer.</summary>
static string closingAction;
/// <summary>
/// Au tout début de chaque image (et image fixe), avant les menus et le contrôleur : chez un
/// menotté, les touches coupées ; chez un escorté, le déplacement aussi. Un menu resté ouvert
/// au moment des menottes est refermé par sa propre touche.
/// </summary>
public static void ApplyRestraintInput( bool frame )
{
if ( frame )
closingAction = null;
var me = Local;
if ( me is null || (!me.Cuffed && me.EscortedBy == Guid.Empty) )
return;
if ( me.Cuffed )
{
foreach ( var action in CuffedActions )
Input.Clear( action );
Input.MouseWheel = Vector2.Zero;
if ( frame )
{
var scene = Game.ActiveScene;
// Une seule touche par image : chaque menu se referme par la sienne.
if ( scene?.GetAllComponents<PhoneMenu>().Any( x => x.IsOpen ) == true )
closingAction = "Phone";
else if ( scene?.GetAllComponents<InventoryMenu>().Any( x => x.IsOpen ) == true )
closingAction = "Inventory";
else if ( scene?.GetAllComponents<WalletMenu>().Any( x => x.IsOpen ) == true )
closingAction = "Wallet";
else if ( scene?.GetAllComponents<HorizonHud>().Any( x => x.MenuOpen ) == true )
closingAction = "JobMenu";
if ( VehicleMenu.Open is not null )
VehicleMenu.Close();
SearchMenu.CloseAll();
}
if ( closingAction is not null )
Input.SetAction( closingAction, true );
}
if ( me.EscortedBy != Guid.Empty )
{
foreach ( var action in MoveActions )
Input.Clear( action );
Input.AnalogMove = Vector3.Zero;
}
}
// Ordres de l'hôte, chez un agent
/// <summary>Chez l'agent : la fouille de police s'ouvre sur ce citoyen (SearchMenu, mode police).</summary>
[Rpc.Owner( NetFlags.HostOnly )]
public void PoliceOpenSearch( Guid target )
{
if ( Scene.Directory.FindByGuid( target )?.GetComponent<RoleplayPlayer>() is { } citizen )
{
PoliceMenu.MarkKnown( citizen.SteamId );
SearchMenu.OpenPolice( citizen );
}
}
/// <summary>Chez l'agent : ce citoyen lui est désormais connu (identité, amende, écrou).</summary>
[Rpc.Owner( NetFlags.HostOnly )]
public void PoliceKnow( ulong steamId ) => PoliceMenu.MarkKnown( steamId );
/// <summary>Chez l'agent à qui on la présente : la carte d'identité de ce citoyen.</summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
public void PolicePresentId()
{
PoliceMenu.MarkKnown( SteamId );
WalletMenu.Present( this );
}
/// <summary>Chez le citoyen : ses amendes impayées, lues à l'accueil du commissariat (JSON), ou l'erreur.</summary>
[Rpc.Owner( NetFlags.HostOnly )]
public void PoliceDeskFines( string json, string error ) => PoliceMenu.ReceiveDesk( json, error );
}
/// <summary>Pose l'état des touches d'un menotté au tout début de l'image, après nos touches (KeybindSystem, -1000).</summary>
public sealed class PoliceInputSystem : GameObjectSystem
{
public PoliceInputSystem( Scene scene ) : base( scene )
{
Listen( Stage.StartUpdate, -900, Frame, "Menottes Horizon RP" );
Listen( Stage.StartFixedUpdate, -900, Fixed, "Menottes Horizon RP (fixe)" );
}
void Frame()
{
if ( Application.IsDedicatedServer || !Game.IsPlaying )
return;
RoleplayPlayer.ApplyRestraintInput( true );
}
void Fixed()
{
if ( Application.IsDedicatedServer || !Game.IsPlaying )
return;
RoleplayPlayer.ApplyRestraintInput( false );
}
}