The player's roleplay state component. Holds synced identity, job, money, health, hunger, inventory and appearance from the host, and runs host-authoritative RPCs for jobs, purchases, banking, character creation and showing ID. Handles camera, restraint, hiding and staff screenshot capture.
namespace HorizonRP;
/// <summary>
/// L'état RP d'un joueur, posé sur son prefab à côté du PlayerController.
///
/// Toutes les valeurs sont écrites par l'hôte seul (<c>SyncFlags.FromHost</c>) :
/// le client les affiche et ne peut que demander un changement par RPC, que
/// l'hôte vérifie avant d'appliquer. C'est la frontière contre la triche.
/// </summary>
[Title( "Joueur RP" )]
[Icon( "badge" )]
public sealed partial class RoleplayPlayer : Component
{
[Sync( SyncFlags.FromHost )] public ulong SteamId { get; set; }
[Sync( SyncFlags.FromHost )] public string RpName { get; set; }
[Sync( SyncFlags.FromHost )] public string JobId { get; set; } = Jobs.DefaultId;
[Sync( SyncFlags.FromHost )] public int Wallet { get; set; }
[Sync( SyncFlags.FromHost )] public int Bank { get; set; }
[Sync( SyncFlags.FromHost )] public float Health { get; set; } = 100;
[Sync( SyncFlags.FromHost )] public float Hunger { get; set; } = 100;
[Sync( SyncFlags.FromHost )] public bool IsStaff { get; set; }
/// <summary>Les véhicules possédés (VehicleManager), en JSON : le menu du garage les lit.</summary>
[Sync( SyncFlags.FromHost )] public string GarageJson { get; set; }
/// <summary>Le numéro de téléphone, tiré du SteamID : le même à chaque connexion.</summary>
public string PhoneNumber => Phone.NumberOf( SteamId );
/// <summary>Vrai une fois le profil chargé : avant, on ne sauvegarde rien.</summary>
public bool ProfileLoaded { get; set; }
public JobDefinition Job => Jobs.Get( JobId );
/// <summary>Le joueur de cette machine, ou null (serveur dédié, chargement).</summary>
public static RoleplayPlayer Local =>
Game.ActiveScene?.GetAllComponents<RoleplayPlayer>().FirstOrDefault( x => x.Network.IsOwner );
public static IEnumerable<RoleplayPlayer> All =>
Game.ActiveScene?.GetAllComponents<RoleplayPlayer>() ?? Enumerable.Empty<RoleplayPlayer>();
/// <summary>Les cases de l'inventaire, en JSON (écrit par l'hôte, lu par l'interface).</summary>
[Sync( SyncFlags.FromHost )] public string InventoryJson { get; set; }
/// <summary>Tenue et morphologie (ClothingContainer de s&box, JSON), appliquées sur toutes les machines.</summary>
[Sync( SyncFlags.FromHost )] public string AppearanceJson { get; set; }
/// <summary>Papiers d'identité : date de naissance (« AAAA-MM-JJ »), sexe (« M »/« F »), date de délivrance.</summary>
[Sync( SyncFlags.FromHost )] public string BirthDate { get; set; }
[Sync( SyncFlags.FromHost )] public string Sex { get; set; }
[Sync( SyncFlags.FromHost )] public string IdIssued { get; set; }
/// <summary>Vrai tant que le personnage n'a pas été créé : l'écran de création s'ouvre.</summary>
[Sync( SyncFlags.FromHost )] public bool NeedsCharacter { get; set; }
/// <summary>Gelé par le staff : il regarde autour de lui mais ne bouge plus.</summary>
[Sync( SyncFlags.FromHost )] public bool Frozen { get; set; }
/// <summary>
/// Mode dieu, donné par le staff (menu admin, Joueurs) : ni dégâts (armes, explosifs, feu,
/// véhicules), ni faim. Jamais sauvegardé : il part avec la connexion, et le joueur qui revient
/// sur le serveur est de nouveau mortel (Julien, 30/10/2026).
/// </summary>
[Sync( SyncFlags.FromHost )] public bool GodMode { get; set; }
/// <summary>Tenu au physgun du staff : sa position est <see cref="HeldPosition"/>, imposée par l'hôte.</summary>
[Sync( SyncFlags.FromHost )] public bool Held { get; set; }
/// <summary>La clé de véhicule tenue en main (« cle:<plaque> »), ou null (VehicleKeys).</summary>
[Sync( SyncFlags.FromHost )] public string HeldKey { get; set; }
/// <summary>
/// Le téléphone est sorti (écrit par le joueur lui-même, PhoneMenu) : il le tient en main et le
/// regarde, à la place de ce qu'il tenait (VehicleKeySystem). <see cref="PhoneSelfie"/> : la
/// caméra avant de l'appareil photo, le bras tendu devant lui.
/// </summary>
[Sync] public bool PhoneOut { get; set; }
[Sync] public bool PhoneSelfie { get; set; }
/// <summary>Le marteau du staff en main (le physgun, StaffTool) : écrit par le joueur, vu de tous.</summary>
[Sync] public bool StaffHammer { get; set; }
/// <summary>
/// Le staff en noclip (écrit par lui-même, StaffTool) : invisible pour tous les autres. Chaque
/// machine pose l'étiquette <see cref="HiddenTag"/> sur son corps, que sa caméra ne dessine pas.
/// </summary>
[Sync] public bool StaffInvisible { get; set; }
public const string HiddenTag = "cache_noclip";
/// <summary>
/// Hôte : réanimé ou mort, le joueur part (fondu au noir) vers l'hôpital ou le point de
/// réapparition. Le temps du trajet, les autres ne le voient pas : il se relevait d'un coup sur
/// place puis disparaissait (Julien, 30/10/2026). Même étiquette que le noclip.
/// </summary>
[Sync( SyncFlags.FromHost )] public bool Evacuating { get; set; }
/// <summary>La vitesse du sprint (unités par seconde).</summary>
public const float RunSpeed = 220f;
[Sync( SyncFlags.FromHost )] public Vector3 HeldPosition { get; set; }
/// <summary>Gelé ou tenu : les touches de déplacement ne répondent plus.</summary>
public bool Restrained => Frozen || Held;
/// <summary>Hôte : l'inventaire qui fait foi.</summary>
public List<InventorySlot> Inventory { get; private set; } = new();
/// <summary>Côté client : l'inventaire lu dans InventoryJson.</summary>
public List<InventorySlot> PublishedInventory
{
get
{
if ( Networking.IsHost )
return Inventory;
if ( parsedInventory != InventoryJson )
{
parsedInventory = InventoryJson;
published = string.IsNullOrEmpty( InventoryJson ) ? new() : Json.Deserialize<List<InventorySlot>>( InventoryJson ) ?? new();
}
return published;
}
}
string parsedInventory;
List<InventorySlot> published = new();
public PlayerProfile ToProfile() => new()
{
SteamId = SteamId,
RpName = RpName,
Wallet = Wallet,
Bank = Bank,
// Jamais la tenue de service ni les armes de service (Police/PoliceService.Service.cs).
Appearance = CivilianAppearance ?? AppearanceJson,
Inventory = Json.Serialize( Inventory.Where( x => !PoliceService.IsServiceSlot( x ) ).ToList() ),
BirthDate = BirthDate,
Sex = Sex,
};
public void ApplyProfile( PlayerProfile profile )
{
RpName = string.IsNullOrWhiteSpace( profile.RpName ) ? RpName : profile.RpName;
Wallet = profile.Wallet;
Bank = profile.Bank;
AppearanceJson = profile.Appearance;
BirthDate = profile.BirthDate;
Sex = profile.Sex;
IdIssued = profile.FirstSeen == default ? DateTime.UtcNow.ToString( "yyyy-MM-dd" ) : profile.FirstSeen.ToString( "yyyy-MM-dd" );
NeedsCharacter = string.IsNullOrWhiteSpace( profile.Appearance );
Inventory = (string.IsNullOrEmpty( profile.Inventory ) ? null : Json.Deserialize<List<InventorySlot>>( profile.Inventory )) ?? new();
Inventory.RemoveAll( x => x.Definition is null || x.Count <= 0 );
FitInPockets();
PublishInventory();
ProfileLoaded = true;
}
// Inventaire (hôte seulement)
/// <summary>
/// Les poches ont rétréci (24 cases, puis 5) : ce qui était plus loin rejoint une case
/// libre, et ce qui n'y tient plus tombe aux pieds du joueur, rien ne se perd.
/// </summary>
void FitInPockets()
{
var outside = Inventory.Where( x => x.Slot < 0 || x.Slot >= InventoryRules.Slots ).OrderBy( x => x.Slot ).ToList();
foreach ( var slot in outside )
{
var free = Enumerable.Range( 0, InventoryRules.Slots ).FirstOrDefault( i => Inventory.All( x => x.Slot != i ), -1 );
if ( free >= 0 )
{
slot.Slot = free;
continue;
}
Inventory.Remove( slot );
DroppedItem.Spawn( slot.Definition, slot.Count, WorldPosition + Vector3.Random.WithZ( 0 ) * 40, slot.Data );
HorizonGame.Current?.AddLog( "poches_pleines", SteamId, $"{slot.Definition.Name} ×{slot.Count} posé au sol (poches réduites à {InventoryRules.Slots} cases)" );
}
}
public void PublishInventory() => InventoryJson = Json.Serialize( Inventory.OrderBy( x => x.Slot ).ToList() );
public float InventoryWeight => InventoryRules.WeightOf( Inventory );
/// <summary>Combien de cet objet peuvent encore entrer (poids et cases).</summary>
public int RoomFor( ItemDefinition item )
{
var byWeight = item.Weight <= 0 ? int.MaxValue : (int)MathF.Floor( (InventoryRules.MaxWeight - InventoryWeight) / item.Weight + 0.0001f );
var inStacks = Inventory.Where( x => x.Item == item.Id ).Sum( x => item.MaxStack - x.Count );
var freeSlots = InventoryRules.Slots - Inventory.Count;
return Math.Max( 0, Math.Min( byWeight, inStacks + freeSlots * item.MaxStack ) );
}
/// <summary>
/// Ajoute en complétant d'abord les piles existantes. Rend le nombre réellement
/// ajouté. Une arme ne s'empile pas et reçoit ses données (<paramref name="data"/>,
/// ou un numéro de série neuf).
/// </summary>
public int AddItem( ItemDefinition item, int count, string data = null )
{
count = Math.Min( count, RoomFor( item ) );
if ( WeaponItems.WeaponOf( item.Id ) is not null )
{
var added = 0;
for ( var i = 0; i < InventoryRules.Slots && added < count; i++ )
{
if ( Inventory.Any( x => x.Slot == i ) )
continue;
var weaponData = added == 0 && data is not null ? data : new WeaponData { Serial = WeaponData.NewSerial() }.Write();
Inventory.Add( new InventorySlot { Slot = i, Item = item.Id, Count = 1, Data = weaponData } );
added++;
}
if ( added > 0 )
PublishInventory();
return added;
}
var left = count;
foreach ( var slot in Inventory.Where( x => x.Item == item.Id && x.Count < item.MaxStack ).OrderBy( x => x.Slot ) )
{
var put = Math.Min( left, item.MaxStack - slot.Count );
slot.Count += put;
left -= put;
if ( left == 0 ) break;
}
for ( var i = 0; i < InventoryRules.Slots && left > 0; i++ )
{
if ( Inventory.Any( x => x.Slot == i ) )
continue;
var put = Math.Min( left, item.MaxStack );
Inventory.Add( new InventorySlot { Slot = i, Item = item.Id, Count = put } );
left -= put;
}
if ( count > 0 )
PublishInventory();
return count;
}
public InventorySlot SlotAt( int slot ) => Inventory.FirstOrDefault( x => x.Slot == slot );
public void RemoveFrom( InventorySlot slot, int count )
{
slot.Count -= count;
if ( slot.Count <= 0 )
Inventory.Remove( slot );
PublishInventory();
}
// Apparence (sur toutes les machines)
string appliedAppearance;
string appliedSex;
int appliedClothingVersion;
/// <summary>Les deux corps réalistes de s&box (« Human »), choisis par le sexe du personnage.</summary>
public const string MaleBody = "models/citizen_human/citizen_human_male.vmdl";
public const string FemaleBody = "models/citizen_human/citizen_human_female.vmdl";
public static string BodyModelFor( string sex ) => sex == "F" ? FemaleBody : MaleBody;
/// <summary>Vrai pendant que l'écran de création applique un aperçu local.</summary>
public bool PreviewingAppearance { get; set; }
/// <summary>Après le placement de la caméra : la vue de face (CameraModes).</summary>
protected override void OnPreRender()
{
if ( this == Local && GetComponent<PlayerController>() is { } controller )
{
if ( OnTheGround )
DownedCamera();
else
CameraModes.PreRender( this, controller );
// Devant un distributeur : la vue face à son écran (UI/AtmScreen.razor).
AtmScreen.PreRender( Scene.Camera );
CameraShake.Apply( Scene.Camera );
}
}
protected override void OnUpdate()
{
// Le sprint : 320 dans le prefab (8 m/s, un sprinteur), bien trop rapide pour du RP (Julien) ;
// 220, soit une course d'environ 5,6 m/s. La marche reste à 110.
// À bout de souffle (Stamina), la course n'est plus que de la marche.
if ( !IsProxy && GetComponent<PlayerController>() is { } walker )
{
// Menotté : pas de course (Police/RoleplayPlayer.Police.cs).
var run = Cuffed ? CuffedSpeed : this == Local && Stamina.Exhausted ? walker.WalkSpeed : RunSpeed;
if ( walker.RunSpeed != run )
walker.RunSpeed = run;
}
// Le staff en noclip, ou un joueur emmené à l'hôpital : caché chez les autres (lui se voit toujours).
var hidden = (StaffInvisible || Evacuating) && this != Local;
if ( GameObject.Tags.Has( HiddenTag ) != hidden )
GameObject.Tags.Set( HiddenTag, hidden );
// Les paquets du Workshop montés après coup : les vêtements qui manquaient existent enfin.
if ( !PreviewingAppearance && (AppearanceJson != appliedAppearance || Sex != appliedSex
|| appliedClothingVersion != WorkshopPackages.ClothingVersion) )
{
appliedAppearance = AppearanceJson;
appliedSex = Sex;
appliedClothingVersion = WorkshopPackages.ClothingVersion;
ApplyAppearance( AppearanceJson );
}
if ( Network.IsOwner )
UpdateRestraint();
UpdateDowned();
Pain.Update( this );
UpdateCapsules();
UpdatePolice();
if ( this == Local && GetComponent<PlayerController>() is { } controller )
{
CameraModes.Update( this, controller );
// Un menu ouvert : immobile, pas seulement sans commandes (UiLock.Halt).
if ( !UiLock.CanMove && !OnTheGround && GameObject.GetComponentInParent<Vehicle>() is null )
controller.WishVelocity = Vector3.Zero;
}
UpdateCreationVisibility();
if ( Networking.IsHost )
UpdateCreationPlace();
}
// La création de personnage : dans la ruelle, à l'écart des autres
bool? wasCreating;
/// <summary>
/// Hôte : un personnage à créer part dans la ruelle de la création ; une fois
/// créé, il revient à un point d'apparition. Vaut pour la première venue comme
/// pour « refaire le personnage » demandé par le staff.
/// </summary>
void UpdateCreationPlace()
{
if ( !ProfileLoaded || wasCreating == NeedsCharacter )
return;
var first = wasCreating is null;
wasCreating = NeedsCharacter;
if ( NeedsCharacter )
TeleportTo( CharacterRules.CreationSpot );
else if ( !first && HorizonGame.Current is { } game )
TeleportTo( game.SpawnPosition() );
}
bool hiddenForCreation;
RealTimeSince sinceVisibility;
/// <summary>
/// Sur chaque machine : un joueur en création est invisible des autres, et
/// celui qui crée le sien ne voit personne (ils sont tous au même endroit).
/// </summary>
void UpdateCreationVisibility()
{
var local = Local;
var hidden = local is not null && this != local && (NeedsCharacter || local.NeedsCharacter);
// Les vêtements arrivent après coup : tant qu'il est caché, on repasse de temps en temps.
if ( hidden == hiddenForCreation && (!hidden || sinceVisibility < 0.5f) )
return;
hiddenForCreation = hidden;
sinceVisibility = 0;
foreach ( var renderer in GameObject.GetComponentsInChildren<ModelRenderer>( true ) )
renderer.Enabled = !hidden;
// Sans collision non plus : deux joueurs qui créent leur personnage sont au même endroit,
// et le corps de l'autre faisait plafond, si bien que le contrôleur accroupissait le sien
// (« duck » à 0,5, premier essai à deux, 29/10/2026).
// L'objet « Colliders » entier : ses composants, le contrôleur les réactive à chaque image
// (couper ceux-là faisait s'accroupir et se relever le personnage sans fin).
foreach ( var child in GameObject.Children.Where( x => x.Name == "Colliders" ) )
child.Enabled = !hidden;
}
bool wasRestrained;
/// <summary>
/// Chez le propriétaire, qui simule son personnage : le gel coupe les touches
/// de déplacement, la prise au physgun impose en plus la position (et coupe la
/// gravité, sinon il retomberait entre deux positions reçues).
/// </summary>
void UpdateRestraint()
{
var controller = GetComponent<PlayerController>();
if ( !controller.IsValid() )
return;
if ( Restrained )
{
if ( Held )
WorldPosition = HeldPosition;
if ( controller.Body.IsValid() )
{
controller.Body.Velocity = Vector3.Zero;
controller.Body.Gravity = !Held;
}
}
if ( Restrained != wasRestrained )
{
wasRestrained = Restrained;
controller.UseInputControls = !Restrained && UiLock.CanMove;
if ( !Restrained && controller.Body.IsValid() )
controller.Body.Gravity = true;
}
}
/// <summary>Habille ce joueur avec une apparence (JSON ClothingContainer).</summary>
public void ApplyAppearance( string json )
{
var dresser = GetComponent<Dresser>();
if ( !dresser.IsValid() || string.IsNullOrWhiteSpace( json ) )
return;
var container = ClothingContainer.CreateFromJson( json );
ApplyAppearance( container );
}
/// <param name="sex">Le sexe à montrer (aperçu de la création) ; null = celui du personnage.</param>
public void ApplyAppearance( ClothingContainer container, string sex = null )
{
var dresser = GetComponent<Dresser>();
if ( !dresser.IsValid() || container is null )
return;
// La peau fixe le corps (homme ou femme) : le Dresser la lit, pose le bon
// modèle et choisit la variante de chaque vêtement. Une apparence enregistrée
// avant le choix du sexe reçoit ici la peau qui va avec.
CharacterRules.EnsureSkin( container, sex ?? Sex );
dresser.Source = Dresser.ClothingSource.Manual;
dresser.UpdateAppearance( container );
_ = TintedSkin.DressAsync( dresser, container );
}
/// <summary>Force la ré-application de l'apparence enregistrée (fin d'aperçu).</summary>
public void ResetAppliedAppearance()
{
appliedAppearance = null;
appliedSex = null;
}
/// <summary>Montre sa carte d'identité aux joueurs proches (portefeuille, touche K).</summary>
[Rpc.Host]
public void RequestShowId()
{
if ( !CallerIsOwner() || NeedsCharacter )
return;
var viewers = All
.Where( x => x != this && x.WorldPosition.Distance( WorldPosition ) <= 200 )
.Select( x => x.Network.Owner )
.Where( x => x is not null )
.ToList();
if ( viewers.Count == 0 )
{
Notify( "Personne à qui la montrer.", "info" );
return;
}
using ( Rpc.FilterInclude( viewers ) )
PresentId();
Notify( viewers.Count == 1 ? "Vous montrez votre carte d'identité." : $"Vous montrez votre carte d'identité à {viewers.Count} personnes.", "info" );
}
/// <summary>Chez ceux à qui on la montre : la carte s'affiche quelques secondes.</summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
void PresentId() => WalletMenu.Present( this );
[Rpc.Host]
public void RequestSaveCharacter( string firstName, string lastName, string birthDate, string sex, string appearanceJson )
{
if ( !CallerIsOwner() || !ProfileLoaded )
return;
// Les deux vérifications tournent toujours (pas de « ?? ») : chacune remplit ses résultats.
var identityError = CharacterRules.ValidateIdentity( birthDate, sex, out var isoBirth, out var cleanSex );
var error = CharacterRules.Validate( firstName, lastName, appearanceJson, cleanSex, out var name, out var cleanJson ) ?? identityError;
if ( error is not null )
{
Notify( error, "erreur" );
return;
}
RpName = name;
AppearanceJson = cleanJson;
BirthDate = isoBirth;
Sex = cleanSex;
NeedsCharacter = false;
Notify( $"Bienvenue à Union City, {name} !", "info" );
HorizonGame.Current?.AddLog( "personnage", SteamId, $"Personnage créé : {name}" );
HorizonGame.Current?.SaveNow( this );
}
/// <summary>
/// L'appel vient-il bien du propriétaire de ce joueur ? Sans ce contrôle,
/// n'importe qui pourrait changer le métier ou vider le compte d'un autre.
/// </summary>
bool CallerIsOwner() => Rpc.Caller is not null && Rpc.Caller.Id == Network.OwnerId;
// Demandes du client : exécutées sur l'hôte, qui décide.
[Rpc.Host]
public void RequestJob( string jobId )
{
if ( !CallerIsOwner() ) return;
HorizonGame.Current?.TryChangeJob( this, jobId );
}
[Rpc.Host]
public void RequestBuy( string itemId )
{
if ( !CallerIsOwner() ) return;
HorizonGame.Current?.TryBuy( this, itemId );
}
[Rpc.Host]
public void RequestDeposit( int amount )
{
if ( !CallerIsOwner() ) return;
HorizonGame.Current?.TryTransfer( this, amount, toBank: true );
}
[Rpc.Host]
public void RequestWithdraw( int amount )
{
if ( !CallerIsOwner() ) return;
HorizonGame.Current?.TryTransfer( this, amount, toBank: false );
}
// Ordres de l'hôte : exécutés chez le propriétaire seul.
[Rpc.Owner]
public void Notify( string message, string kind )
{
HudNotifications.Push( message, kind );
}
/// <summary>
/// Le déplacement est simulé par le propriétaire : si l'hôte bougeait l'objet
/// lui-même, la position renvoyée par le client l'écraserait aussitôt.
/// </summary>
/// <summary>
/// Capture d'écran demandée par le staff : la vue du joueur (la caméra du jeu,
/// sans l'interface) est rendue dans une texture, compressée en JPEG et envoyée
/// à l'API avec le jeton à usage unique que l'hôte a obtenu pour elle.
/// </summary>
[Rpc.Owner( NetFlags.HostOnly )]
public void TakeScreenshot( string token, string uploadUrl )
{
if ( string.IsNullOrEmpty( token ) || string.IsNullOrEmpty( uploadUrl ) )
return;
_ = CaptureAsync( token, uploadUrl );
}
async Task CaptureAsync( string token, string uploadUrl )
{
var camera = Scene.Camera;
if ( camera is null )
return;
// 1280 de large au plus, au format de l'écran du joueur.
var width = Math.Min( 1280, Screen.Width.CeilToInt() );
var height = Math.Max( 1, (width * Screen.Height / Math.Max( 1, Screen.Width )).CeilToInt() );
var texture = Texture.CreateRenderTarget( "horizon-capture", ImageFormat.RGBA8888, new Vector2( width, height ) );
try
{
camera.RenderToTexture( texture );
await Task.Frame();
var jpeg = texture.GetBitmap( 0 )?.ToJpg( 75 );
if ( jpeg is null || jpeg.Length == 0 )
return;
// En tranches de 15 000 caractères, l'une après l'autre : au-delà d'environ
// 20 ko, s&box envoie le corps par morceaux et l'hébergeur ne le transmet
// pas à PHP (le jeton arrivait vide : « 403, Jeton invalide »).
const int slice = 15000;
var base64 = Convert.ToBase64String( jpeg );
var parts = (base64.Length + slice - 1) / slice;
for ( var part = 0; part < parts; part++ )
{
var piece = base64.Substring( part * slice, Math.Min( slice, base64.Length - part * slice ) );
await Http.RequestJsonAsync<Backend.SimpleResponse>( uploadUrl, "POST",
Http.CreateJsonContent( new { token, image = piece, part, parts } ) );
}
}
catch ( Exception e )
{
Log.Warning( $"[Horizon] Capture d'écran : {e.Message}" );
}
finally
{
texture.Dispose();
}
}
[Rpc.Owner]
public void TeleportTo( Vector3 position )
{
WorldPosition = position;
var controller = GetComponent<PlayerController>();
if ( controller.IsValid() && controller.Body.IsValid() )
controller.Body.Velocity = Vector3.Zero;
}
}