Phone/PhoneCamera.cs

The phone's camera app for the local player. Renders a live viewfinder through a separate camera (rear follows the player's view, front is a selfie orbit rig), takes a 600x800 JPEG on shutter, and uploads it in base64 slices with a one-time token to a host-provided upload URL.

NetworkingHttp Calls
namespace HorizonRP;

/// <summary>
/// L'appareil photo du téléphone, chez le joueur local. Tant que l'application est ouverte, une
/// caméra à part rend la ville dans <see cref="View"/>, le viseur de l'écran : la caméra arrière
/// regarde où regarde le joueur, un peu devant ses yeux (le téléphone tenu devant lui) ; la caméra
/// avant est à bout de bras, face à son visage (selfie). Glisser sur le viseur tourne le regard du
/// joueur (arrière) ou fait tourner le téléphone autour de lui (avant).
///
/// Le corps du joueur porte l'étiquette « viewer » à la première personne, que la caméra du jeu
/// exclut : la caméra arrière l'exclut aussi, la caméra avant non, et le selfie le montre donc
/// même à la première personne.
///
/// Une photo est rendue en 600 × 800, compressée en JPEG, puis envoyée par le client lui-même à
/// l'API (photoUpload) avec un jeton à usage unique que l'hôte a demandé pour lui, en tranches
/// comme les captures d'écran du staff (RoleplayPlayer.CaptureAsync).
/// </summary>
public static class PhoneCamera
{
	const int ViewWidth = 360, ViewHeight = 480;
	const int PhotoWidth = 600, PhotoHeight = 800;

	/// <summary>Le viseur (rendu à chaque image tant que l'appareil est ouvert).</summary>
	public static Texture View { get; private set; }

	/// <summary>La caméra avant (selfie), sinon l'arrière.</summary>
	public static bool Front { get; set; }

	/// <summary>Selfie : le téléphone tourné autour du joueur (degrés).</summary>
	public static float OrbitYaw { get; set; }
	public static float OrbitPitch { get; set; }

	/// <summary>Photos prises mais pas encore arrivées à l'API.</summary>
	public static int Sending => waiting.Count + (uploading ? 1 : 0);

	/// <summary>La dernière photo prise (vignette de l'appareil), même avant son envoi.</summary>
	public static Texture LastShot { get; private set; }

	/// <summary>L'objectif avant, dans le repère du regard : au téléphone du bras tendu, côté écran.</summary>
	public static Vector3 SelfieLens { get; set; } = new( 20, -4, 1 );

	/// <summary>Le zoom (molette) : 0,5× (grand angle) à 5×.</summary>
	public static float Zoom { get; set; } = 1f;

	public static void ZoomBy( float wheel ) =>
		Zoom = Math.Clamp( MathF.Round( (Zoom * MathF.Pow( 1.15f, wheel )) * 10 ) / 10, 0.5f, 5f );

	static GameObject rig;
	static CameraComponent camera;
	static bool uploading;
	static readonly Queue<(byte[] Jpeg, Texture Texture)> waiting = new();

	/// <summary>Chaque image, application ouverte : la caméra suit le joueur.</summary>
	public static void Update( RoleplayPlayer me )
	{
		if ( me is null || Application.IsDedicatedServer )
			return;
		if ( !rig.IsValid() )
			Create();
		Place( me );
	}

	/// <summary>L'application se ferme : la caméra disparaît (elle coûte un rendu par image).</summary>
	public static void Stop()
	{
		if ( rig.IsValid() )
			rig.Destroy();
		rig = null;
		camera = null;
	}

	static void Create()
	{
		View ??= Texture.CreateRenderTarget( "horizon-viseur", ImageFormat.RGBA8888, new Vector2( ViewWidth, ViewHeight ) );
		rig = new GameObject( false, "Appareil photo du téléphone" );
		rig.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		camera = rig.Components.Create<CameraComponent>();
		camera.IsMainCamera = false;
		camera.RenderTarget = View;
		camera.ZNear = 2;
		rig.Enabled = true;
	}

	static void Place( RoleplayPlayer me )
	{
		var controller = me.GetComponent<PlayerController>();
		var eye = controller?.EyePosition ?? me.WorldPosition + Vector3.Up * 64;
		var angles = controller?.EyeAngles ?? me.WorldRotation.Angles();

		if ( Front )
		{
			// Dans le téléphone, au bout du bras tendu (VehicleKeySystem.PhoneSelfieWrist), tourné
			// vers le visage : la main et le téléphone sont derrière l'objectif.
			var frame = angles.ToRotation() * VehicleKeySystem.SelfieOrbit;
			var at = eye + frame * SelfieLens;
			rig.WorldPosition = at;
			rig.WorldRotation = Rotation.LookAt( eye + Vector3.Down * 3 - at, Vector3.Up );
			camera.FieldOfView = 68 / Zoom;
			camera.RenderExcludeTags.Set( "viewer", false );
		}
		else
		{
			var rotation = angles.ToRotation();
			rig.WorldPosition = eye + rotation.Forward * 14;
			rig.WorldRotation = rotation;
			camera.FieldOfView = 58 / Zoom;
			camera.RenderExcludeTags.Set( "viewer", true );
		}
		camera.RenderExcludeTags.Set( "viewmodel", true );
		camera.RenderExcludeTags.Set( FirstPersonLegs.Tag, true );
	}

	/// <summary>Glisser sur le viseur : le regard (arrière) ou le bras (avant) tourne.</summary>
	public static void Aim( RoleplayPlayer me, Vector2 delta )
	{
		if ( Front )
		{
			OrbitYaw = Math.Clamp( OrbitYaw - delta.x * 0.3f, -75, 75 );
			OrbitPitch = Math.Clamp( OrbitPitch + delta.y * 0.2f, -35, 35 );
			return;
		}
		if ( me?.GetComponent<PlayerController>() is not { } controller )
			return;
		var angles = controller.EyeAngles;
		angles.yaw -= delta.x * 0.25f;
		angles.pitch = Math.Clamp( angles.pitch + delta.y * 0.2f, -80, 80 );
		controller.EyeAngles = angles;
	}

	/// <summary>Déclencher : la photo est rendue, gardée, et un jeton d'envoi est demandé à l'hôte.</summary>
	public static void Shoot()
	{
		if ( camera is null || !rig.IsValid() )
			return;

		var target = Texture.CreateRenderTarget( "horizon-photo", ImageFormat.RGBA8888, new Vector2( PhotoWidth, PhotoHeight ) );
		camera.RenderToTexture( target );
		var bitmap = target.GetBitmap( 0 );
		target.Dispose();
		var jpeg = bitmap?.ToJpg( 82 );
		if ( jpeg is null || jpeg.Length == 0 )
		{
			HudNotifications.Push( "Appareil photo : la photo n'a pas pu être prise.", "erreur" );
			return;
		}

		LastShot = bitmap.ToTexture();
		waiting.Enqueue( (jpeg, LastShot) );
		PhoneClient.Version++;
		PhoneSystem.Current?.RequestPhotoToken( Front ? "avant" : "arriere" );
	}

	/// <summary>L'hôte a rendu un jeton : la plus ancienne photo en attente part à l'API.</summary>
	public static void Upload( string token, string uploadUrl )
	{
		if ( waiting.Count == 0 )
			return;
		var (jpeg, texture) = waiting.Dequeue();
		if ( string.IsNullOrEmpty( token ) || string.IsNullOrEmpty( uploadUrl ) )
		{
			HudNotifications.Push( "Photos : la galerie est indisponible pour le moment.", "erreur" );
			PhoneClient.Version++;
			return;
		}
		_ = UploadAsync( token, uploadUrl, jpeg, texture );
	}

	sealed class UploadResponse
	{
		public bool Ok { get; set; }
		public string Error { get; set; }
		public PhonePhoto Photo { get; set; }
	}

	static async Task UploadAsync( string token, string uploadUrl, byte[] jpeg, Texture texture )
	{
		uploading = true;
		try
		{
			// En tranches de 15 000 caractères, l'une après l'autre (voir RoleplayPlayer.CaptureAsync).
			const int slice = 15000;
			var base64 = Convert.ToBase64String( jpeg );
			var parts = (base64.Length + slice - 1) / slice;
			UploadResponse last = null;
			for ( var part = 0; part < parts; part++ )
			{
				var piece = base64.Substring( part * slice, Math.Min( slice, base64.Length - part * slice ) );
				last = await Http.RequestJsonAsync<UploadResponse>( uploadUrl, "POST",
					Http.CreateJsonContent( new { token, image = piece, part, parts } ) );
				if ( last is null || !last.Ok )
					break;
			}
			if ( last?.Photo is { } photo )
			{
				PhoneClient.KeepPhoto( photo.Id, texture );
				PhoneClient.Photos.RemoveAll( x => x.Id == photo.Id );
				PhoneClient.Photos.Insert( 0, photo );
			}
			else
			{
				HudNotifications.Push( $"Photos : envoi refusé ({last?.Error ?? "API injoignable"}).", "erreur" );
			}
		}
		catch ( Exception e )
		{
			Log.Warning( $"[Horizon] Photo : {e.Message}" );
			HudNotifications.Push( "Photos : la photo n'a pas pu être enregistrée.", "erreur" );
		}
		finally
		{
			uploading = false;
			PhoneClient.Version++;
		}
	}
}