Partial of RoleplayPlayer that handles the downed/knocked state: it builds a local, non-networked ragdoll from the player's body for each machine, applies throw impulses, spawns blood pools and smears, drives the downed camera, and handles proximity revive (E) and search (G) interaction on the local player. Revive and search progress are shown in HUD and sent to the host as requests.
namespace HorizonRP;
/// <summary>
/// La mort, sur chaque machine (la décision est à l'hôte : HorizonGame.Death.cs). Inconscient, le
/// corps tombe en vrai ragdoll (ModelPhysics sur le corps du personnage, vêtements compris), la
/// capsule du joueur ne le gêne plus.
///
/// <b>Chaque machine simule son propre ragdoll, porté par un objet LOCAL à la racine de la scène</b>
/// (jamais en réseau) dont le ModelPhysics pilote les os du vrai corps. Deux essais avant celui-ci,
/// le 30/10/2026, sur le serveur dédié :
/// - le ModelPhysics ajouté par chaque machine à l'objet du joueur : il porte des [Sync], les
/// tables de synchronisation du joueur ne concordaient plus et plus rien ne passait — compte à
/// rebours figé à 5:00, réanimation jamais reçue, corps vu debout ;
/// - créé par le propriétaire puis publié (Network.Refresh) : le Refresh renvoyait tout l'arbre du
/// joueur, objets locaux compris — les bras de première personne et l'arme du mort restaient
/// figés en l'air chez les autres, et l'état du moment (capsules éteintes) s'y figeait : un
/// « fantôme » qu'on traversait et que les balles ne touchaient plus.
/// Les corps ne sont donc plus synchronisés : chacun voit tomber le sien, du même élan.
///
/// Chez lui, plus de commandes ; chez lui, plus de commandes, la vue en troisième personne
/// tourne lentement autour du corps (CameraModes) et l'écran « Inconscient » montre le compte à
/// rebours (HorizonHud). Un EMS proche voit « Maintenir E : réanimer » et sa barre.
/// </summary>
public sealed partial class RoleplayPlayer
{
[Sync( SyncFlags.FromHost )] public bool Downed { get; set; }
/// <summary>Les secondes qui restent avant la mort (l'hôte les décompte).</summary>
[Sync( SyncFlags.FromHost )] public int DownedLeft { get; set; }
/// <summary>
/// Renversé par un véhicule sans perdre connaissance (HorizonGame.RunOver.cs) : le même
/// ragdoll quelques secondes, puis on se relève là où l'on est retombé.
/// </summary>
[Sync( SyncFlags.FromHost )] public bool Knocked { get; set; }
/// <summary>Hôte : le numéro du dernier renversement, pour qu'un ancien n'en termine pas un nouveau.</summary>
public int KnockSerial { get; set; }
/// <summary>Hôte : ce qui l'a mis à terre (« abattu au M4A1 par … »), rappelé au journal s'il en meurt.</summary>
public string DownCause { get; set; }
/// <summary>Au sol en ragdoll : inconscient ou renversé.</summary>
public bool OnTheGround => Downed || Knocked;
// L'élan d'une projection, en attente du ragdoll (créé sur chaque machine à son rythme).
// Le sang sous un inconscient : la flaque en cours, et où la dernière goutte est tombée.
Blood.Stain pool;
Vector3 lastBloodAt, lastBodyAt;
RealTimeSince downSince, stillSince;
Vector3 throwVelocity, throwSpin;
RealTimeSince throwSince = 100;
int throwFrames;
bool shownDowned;
/// <summary>Le ragdoll est-il monté ? À terre seulement : un inconscient installé dans un véhicule par les secours reste assis.</summary>
bool ragdollShown;
ModelPhysics ragdoll;
/// <summary>L'objet local qui porte le ragdoll (StartRagdoll).</summary>
GameObject ragdollHolder;
bool savedThirdPersonDowned;
readonly List<Collider> hiddenColliders = new();
/// <summary>Peut réanimer : un secouriste de l'UCFD en service (Ems/), ou le staff si le réglage le permet.</summary>
public static bool CanRevive( RoleplayPlayer p ) => p is not null && (p.IsEms || p.IsStaff && ServerSettings.StaffRevives);
/// <summary>Où est le corps : le bassin du ragdoll, sinon le joueur.</summary>
public Vector3 BodyPosition =>
GetComponent<PlayerController>()?.Renderer is { } body && body.TryGetBoneTransform( "pelvis", out var t ) ? t.Position : WorldPosition;
// Chez soi : l'inconscient visé et la barre. Un EMS le réanime ; tout autre le fouille (son
// argent liquide, Money/Cash.cs).
public static RoleplayPlayer ReviveTarget { get; private set; }
public static float ReviveProgress { get; private set; }
public static bool ReviveIsSearch { get; private set; }
/// <summary>La durée d'une fouille, E maintenu (s).</summary>
public const float SearchSeconds = 3f;
static RealTimeSince sinceSearch = 10;
void UpdateDowned()
{
// Chez chaque client : les réglages publiés par l'hôte (HorizonGame.SettingsJson).
if ( this == Local )
ServerSettings.Receive( HorizonGame.Current?.SettingsJson );
if ( OnTheGround != shownDowned )
{
shownDowned = OnTheGround;
downSince = 0;
pool = null;
lastBloodAt = lastBodyAt = BodyPosition;
if ( this == Local )
UiLock.Set( "inconscient", OnTheGround );
}
// Le corps tombe en ragdoll au sol ; assis (un inconscient que les secours ont installé dans
// un véhicule, Ems/EmsActions.cs), il garde la pose assise et suit le véhicule.
var wantRagdoll = OnTheGround && !Seated;
if ( wantRagdoll != ragdollShown )
{
ragdollShown = wantRagdoll;
if ( wantRagdoll )
StartRagdoll();
else
StopRagdoll();
lastBloodAt = lastBodyAt = BodyPosition;
}
ApplyPendingThrow();
UpdateBlood();
if ( this == Local )
{
UpdateRescue();
Blood.Tick();
Heartbeat.Update( this );
}
}
void StartRagdoll()
{
var controller = GetComponent<PlayerController>();
var body = controller?.Renderer;
if ( body is null )
return;
// La capsule et le corps physique du joueur s'effacent : le ragdoll tomberait dessus.
hiddenColliders.Clear();
foreach ( var c in GameObject.Components.GetAll<Collider>( FindMode.EnabledInSelf ) )
{
c.Enabled = false;
hiddenColliders.Add( c );
}
if ( controller.Body is { } rb )
rb.MotionEnabled = false;
// L'objet du ragdoll : local, à la racine (sous le joueur, il passerait pour une partie de
// l'objet en réseau), à l'endroit du corps.
ragdollHolder = new GameObject( true, $"Ragdoll – {RpName}" );
ragdollHolder.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
ragdollHolder.WorldTransform = body.WorldTransform;
ragdoll = ragdollHolder.Components.Create<ModelPhysics>();
ragdoll.Renderer = body;
ragdoll.Model = body.Model;
if ( this == Local )
{
savedThirdPersonDowned = controller.ThirdPerson;
controller.ThirdPerson = true;
}
}
/// <summary>
/// Chez tous : un inconscient saigne. Une fois le corps posé, une seule flaque naît sous le
/// buste et s'étale lentement (de 16 à ~38 pouces, un mètre environ, en une minute). Si on le
/// traîne, elle s'arrête de grandir et des traînées se posent dans le sens du mouvement ;
/// arrêté, une nouvelle flaque, qui grandit de nouveau.
/// </summary>
void UpdateBlood()
{
if ( !Downed || !ragdoll.IsValid() )
return;
var at = BodyPosition;
if ( at.Distance( lastBodyAt ) > 1.5f )
stillSince = 0;
lastBodyAt = at;
if ( downSince < 1.5f )
return;
// Traîné loin de sa flaque : elle se fige, la traînée commence.
if ( pool is not null && at.WithZ( 0 ).Distance( pool.Position.WithZ( 0 ) ) > 30f )
{
if ( pool.Decal.IsValid() )
pool.From = pool.To = pool.Decal.Size.x;
// La traînée part du bord de la flaque, sans trou entre les deux.
lastBloodAt = pool.Position;
pool = null;
}
if ( pool is null && stillSince > 0.8f )
{
pool = Blood.OnGround( Blood.Kind.Pool, Scene, at, 16f, Random.Shared.Float( 34f, 42f ), 60f, GameObject );
lastBloodAt = at;
}
else if ( pool is null && at.WithZ( 0 ).Distance( lastBloodAt.WithZ( 0 ) ) > 8f )
{
// Un tronçon de traînée de la dernière position à celle-ci, allongé pour chevaucher le
// précédent : bout à bout, ils dessinent un trait continu qui suit le corps.
var along = (at - lastBloodAt).WithZ( 0 );
var mid = (at + lastBloodAt) / 2;
Blood.OnGround( Blood.Kind.Smear, Scene, mid, along.Length * 1.9f + 6f, ignore: GameObject, along: along.Normal, width: Random.Shared.Float( 12f, 16f ) );
lastBloodAt = at;
}
}
/// <summary>Chez tous (HorizonGame.BroadcastThrow) : l'élan à donner au corps.</summary>
public void QueueThrow( Vector3 velocity, Vector3 spin )
{
throwVelocity = velocity;
throwSpin = spin;
throwSince = 0;
throwFrames = 0;
}
/// <summary>
/// Dès que le ragdoll a ses corps (une image ou deux après sa création) : chacun part avec
/// la vitesse du choc plus sa part de la rotation (v + ω × r autour du centre du corps),
/// ce qui fait basculer le corps entier au lieu de le faire glisser d'un bloc. Posée sur
/// quatre images : à la première, la physique des corps naissants l'écrasait (mesuré :
/// 118 pouces/s au lieu de 310).
/// </summary>
void ApplyPendingThrow()
{
if ( throwSince > 1.5f || throwFrames >= 4 || !ragdoll.IsValid() || ragdoll.Bodies.Count == 0 )
return;
var bodies = ragdoll.Bodies.Select( x => x.Component ).Where( x => x.IsValid() ).ToList();
if ( bodies.Count == 0 )
return;
var center = bodies.Aggregate( Vector3.Zero, ( a, b ) => a + b.WorldPosition ) / bodies.Count;
foreach ( var rb in bodies )
{
rb.Velocity = throwVelocity + Vector3.Cross( throwSpin, rb.WorldPosition - center );
rb.AngularVelocity = throwSpin;
}
throwFrames++;
}
/// <summary>Parti au sol (déconnexion) : son ragdoll, à la racine de la scène, part avec lui.</summary>
protected override void OnDestroy()
{
if ( ragdollHolder.IsValid() )
ragdollHolder.Destroy();
}
void StopRagdoll()
{
var controller = GetComponent<PlayerController>();
// Chez lui : il se relève là où son corps est retombé, posé sur le sol sous le bassin (pas
// s'il vient d'être assis dans un véhicule : le siège le tient).
if ( Network.IsOwner && ragdoll.IsValid() && controller is not null && !Seated )
{
var at = BodyPosition;
var ground = Scene.Trace.Ray( at + Vector3.Up * 20, at + Vector3.Down * 200 ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "player", "trigger" ).Run();
WorldPosition = ground.Hit ? ground.EndPosition : at;
if ( controller.Body.IsValid() )
controller.Body.Velocity = Vector3.Zero;
}
if ( ragdollHolder.IsValid() )
ragdollHolder.Destroy();
ragdollHolder = null;
ragdoll = null;
// Installé assis par les secours : ses capsules restent éteintes, rendues au siège qui les
// rallumera quand il descendra (UpdateCapsules).
foreach ( var c in hiddenColliders )
if ( c.IsValid() )
{
if ( Seated )
seatHiddenColliders.Add( c );
else
c.Enabled = true;
}
hiddenColliders.Clear();
if ( controller is null )
return;
if ( controller.Body is { } rb )
rb.MotionEnabled = true;
if ( controller.Renderer is { } body )
body.GameObject.LocalTransform = new Transform( Vector3.Zero, Rotation.Identity );
if ( this == Local )
controller.ThirdPerson = savedThirdPersonDowned;
}
/// <summary>
/// Chez soi : près d'un inconscient, E maintenu le réanime (un secouriste, ou le staff si le
/// réglage le permet), G maintenu le fouille (tout le monde : son argent, ses poches, comme un
/// coffre). La fouille était sur E pour qui ne pouvait pas réanimer, et rien ne l'annonçait
/// (Julien, 30/10/2026 : « touche G sur son cadavre pour fouiller son inventaire »).
/// </summary>
void UpdateRescue()
{
RoleplayPlayer target = null;
var canRevive = CanRevive( this );
if ( !OnTheGround && !UiLock.IsLocked && GameObject.GetComponentInParent<Vehicle>() is null )
{
var eye = GetComponent<PlayerController>()?.EyePosition ?? WorldPosition;
var forward = Scene.Camera?.WorldRotation.Forward ?? WorldRotation.Forward;
target = All.Where( p => p != this && p.Downed )
.Select( p => (p, d: p.BodyPosition.Distance( WorldPosition ), aim: Vector3.Dot( (p.BodyPosition - eye).Normal, forward )) )
.Where( x => x.d < 110 && x.aim > 0.55f )
.OrderBy( x => x.d ).Select( x => x.p ).FirstOrDefault();
}
if ( target != ReviveTarget )
ReviveProgress = 0;
ReviveTarget = target;
ReviveCanRevive = canRevive;
if ( target is null )
return;
var reviving = canRevive && Input.Down( "use" );
// Juste après une fouille, on ne la recommence pas aussitôt.
var searching = !reviving && Input.Down( "Search" ) && sinceSearch >= 2f;
if ( reviving != !ReviveIsSearch && (reviving || searching) )
ReviveProgress = 0;
if ( reviving || searching )
ReviveIsSearch = searching;
if ( !reviving && !searching )
{
ReviveProgress = 0;
return;
}
ReviveProgress += Time.Delta / MathF.Max( 0.5f, searching ? SearchSeconds : ServerSettings.ReviveSeconds );
if ( ReviveProgress < 1 )
return;
ReviveProgress = 0;
if ( searching )
{
// La fouille finie : sa fenêtre (son argent, ses poches, à prendre).
sinceSearch = 0;
SearchMenu.Open( target );
}
else
HorizonGame.Current?.RequestRevive( target.GameObject.Id );
}
/// <summary>Chez soi : peut-on réanimer l'inconscient visé (sinon, seulement le fouiller) ?</summary>
public static bool ReviveCanRevive { get; private set; }
/// <summary>Chez l'inconscient : la vue tourne lentement autour de son corps.</summary>
public void DownedCamera()
{
var camera = Scene.Camera;
if ( !camera.IsValid() )
return;
var center = BodyPosition + Vector3.Up * 10;
var around = Rotation.FromYaw( RealTime.Now * 9f ) * Vector3.Forward * 130 + Vector3.Up * 85;
var trace = Scene.Trace.Ray( center, center + around ).IgnoreGameObjectHierarchy( GameObject ).WithoutTags( "player", "trigger" ).Radius( 6 ).Run();
camera.WorldPosition = trace.Hit ? trace.EndPosition : center + around;
camera.WorldRotation = Rotation.LookAt( center - camera.WorldPosition, Vector3.Up );
}
}