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.
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++;
}
}
}