RoleplayPlayer.cs

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.

NetworkingHttp CallsEncoded Data
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:&lt;plaque&gt; »), 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;
	}
}