Host-side death and downed system for the HorizonRP gamemode. Puts a player unconscious at zero health, ticks the down timer, handles the drop of held items and cash, lets an on-duty EMS revive them via a host-checked RPC, and respawns after a screen fade.
namespace HorizonRP;
/// <summary>
/// La mort, côté hôte. À zéro point de vie (balle, explosion, faim, feu, staff) on ne meurt pas :
/// on tombe <b>inconscient</b> (<see cref="Down"/>) — corps au sol, compte à rebours
/// (ServerSettings.DownSeconds, 5 minutes). Un <b>EMS</b> (le métier « medecin ») — ou le staff si
/// le réglage le permet — qui maintient E sur le corps le <b>réanime</b> (<see cref="Revive"/>) :
/// fondu au noir, réveil à l'hôpital, rien de perdu. Sinon, à zéro, la <b>mort</b>
/// (<see cref="Die"/>) : les affaires et l'argent sur soi perdus (selon les réglages),
/// réapparition devant l'hôpital. Tout est réglable dans les deux panneaux (Réglages).
/// </summary>
public sealed partial class HorizonGame
{
/// <summary>Les réglages du serveur, de l'hôte vers tous (ServerSettings.Publish / Receive).</summary>
[Sync( SyncFlags.FromHost )] public string SettingsJson { get; set; }
/// <summary>Hôte : le joueur tombe inconscient.</summary>
public void Down( RoleplayPlayer player, string cause, RoleplayPlayer by = null )
{
if ( player is null || player.Downed || player.NeedsCharacter )
return;
player.Health = 0;
player.Downed = true;
player.DownedLeft = (int)MathF.Max( 1, ServerSettings.DownSeconds );
player.Held = false;
// Ce qu'il avait en main tombe près du corps et se ramasse (Julien, 30/10/2026) : l'arme
// sortie (WeaponSystem.DropOnDown), ou l'objet de sa poche qu'il tenait (clé, prop…). Les
// billets, eux, restent son argent, à fouiller sur le corps.
if ( Cash.IsHeld( player ) )
player.HeldKey = null;
else if ( player.HeldKey is { Length: > 0 } held )
{
player.HeldKey = null;
if ( player.Inventory.FirstOrDefault( x => x.Item == held ) is { Definition: { } item } slot )
{
var data = slot.Data;
player.RemoveFrom( slot, 1 );
DroppedItem.Spawn( item, 1, NearBody( Scene, player ), data );
AddLog( "objet_lache", player.SteamId, $"{item.Name} tombé au sol (inconscient)" );
}
}
WeaponSystem.Current?.DropOnDown( player );
// Tombé d'un véhicule : on le sort, le corps ne reste pas assis au volant.
if ( player.GetComponent<PlayerController>() is { } pc && pc.GameObject.GetComponentInParent<Vehicle>() is { } car )
foreach ( var seat in car.Seats )
if ( seat.GetOccupant() == pc )
seat.Eject( pc );
var auteur = by is null ? "" : $", par {by.RpName} ({by.SteamId})";
player.DownCause = $"{cause}{auteur}";
AddLog( "inconscient", player.SteamId, $"{player.RpName} inconscient : {cause}{auteur}" );
}
/// <summary>Au sol près du joueur : un pas à côté, au hasard, posé sur le sol.</summary>
public static Vector3 NearBody( Scene scene, RoleplayPlayer player )
{
var side = Rotation.FromYaw( Random.Shared.Float( 0, 360 ) ).Forward * Random.Shared.Float( 25, 45 );
var start = player.WorldPosition + side + Vector3.Up * 40;
var ground = scene.Trace.Ray( start, start + Vector3.Down * 200 ).IgnoreGameObjectHierarchy( player.GameObject ).WithoutTags( "player", "trigger" ).Run();
return ground.Hit ? ground.EndPosition : player.WorldPosition;
}
/// <summary>Hôte, chaque seconde : le compte à rebours des inconscients.</summary>
void TickDowned()
{
foreach ( var player in RoleplayPlayer.All.Where( x => x.Downed ).ToList() )
{
player.DownedLeft--;
if ( player.DownedLeft <= 0 )
Die( player );
}
}
/// <summary>Hôte : personne n'est venu. Les pertes selon les réglages, puis devant l'hôpital.</summary>
public void Die( RoleplayPlayer player )
{
if ( player is null )
return;
var lost = new List<string>();
if ( ServerSettings.LoseInventory && player.Inventory.Count > 0 )
{
// Les poches vidées, sans le message du staff (ClearInventory) : c'est la mort. Ce qu'il
// avait tombe autour du corps (une arme garde sa série) : le cadavre se fouille encore,
// par terre, comme son argent.
WeaponSystem.Current?.Holster( player );
player.HeldKey = null;
var body = player.BodyPosition;
var names = new List<string>();
foreach ( var slot in player.Inventory.ToList() )
{
if ( slot.Definition is { } item )
{
var near = body + Vector3.Random.WithZ( 0 ).Normal * Random.Shared.Float( 12f, 34f );
var ground = Scene.Trace.Ray( near + Vector3.Up * 30, near + Vector3.Down * 100 )
.IgnoreGameObjectHierarchy( player.GameObject ).WithoutTags( "player", "ragdoll", "trigger" ).Run();
DroppedItem.Spawn( item, slot.Count, ground.Hit ? ground.HitPosition : near, slot.Data );
names.Add( slot.Count > 1 ? $"{item.Name} ×{slot.Count}" : item.Name );
}
player.RemoveFrom( slot, slot.Count );
}
if ( names.Count > 0 )
AddLog( "mort_affaires", player.SteamId, $"Affaires de {player.RpName} tombées près du corps : {string.Join( ", ", names )}" );
lost.Add( "vos affaires (restées sur place)" );
}
if ( ServerSettings.LoseCash && player.Wallet > 0 )
{
// L'argent qu'il avait sur lui reste par terre, là où était le corps : une liasse que
// n'importe qui peut ramasser (Money/Cash.cs).
lost.Add( $"{Format.Money( player.Wallet )} sur vous (resté sur place)" );
CashDrop.Spawn( player.Wallet, player.BodyPosition, $"laissé par {player.RpName} ({player.SteamId}) à sa mort" );
player.Wallet = 0;
}
player.Downed = false;
player.DownedLeft = 0;
player.Knocked = false;
// Vie et faim pleines tout de suite : à zéro pendant le fondu, la faim (TickSecond) le
// remettait à terre.
player.Health = 100;
player.Hunger = 100;
// Mort à l'arrière d'une ambulance : on le descend avant le déplacement (assis, le siège le retiendrait).
PoliceService.Unseat( player );
var points = ServerSettings.RespawnPoints.Where( OnTheMap ).ToList();
var at = points.Count > 0 ? Random.Shared.FromList( points ) : null;
var message = lost.Count > 0 ? $"Vous êtes mort. Vous avez perdu {string.Join( " et ", lost )}." : "Vous êtes mort.";
// La cause rappelée : le journal dit qui a tué, avec quoi, pas seulement qu'il est mort.
var cause = string.IsNullOrEmpty( player.DownCause ) ? "" : $" ({player.DownCause})";
AddLog( "mort", player.SteamId, $"Mort de {player.RpName}{cause}" + (lost.Count > 0 ? $" : perd {string.Join( ", ", lost )}" : "") );
player.DownCause = null;
SaveNow( player );
_ = AfterFade( player, at, message, "erreur", respawn: at is null );
}
/// <summary>Hôte : réanimé à temps. Réveil à l'hôpital, rien de perdu.</summary>
public void Revive( RoleplayPlayer player, RoleplayPlayer by )
{
if ( player is null || !player.Downed )
return;
player.Downed = false;
player.DownedLeft = 0;
player.Knocked = false;
// Soigné entièrement, tout de suite (voir Die).
player.Health = 100;
player.Hunger = 100;
var auteur = by is null ? "le staff" : $"{by.RpName} ({by.SteamId})";
AddLog( "reanimation", player.SteamId, $"{player.RpName} réanimé par {auteur}" );
// Le son de la réanimation, là où est le corps, chez tous (Death/Pain.cs) : avant le fondu et
// le déplacement à l'hôpital.
PlayRevived( player.BodyPosition, Random.Shared.Int( 0, Pain.ReviveSounds - 1 ) );
// Réanimé à l'arrière d'une ambulance (Ems/EmsActions.cs) : il descend avant le réveil à l'hôpital.
PoliceService.Unseat( player );
by?.Notify( $"{player.RpName} est sauvé : il se réveillera à l'hôpital.", "info" );
var wake = ServerSettings.WakePoint is { } point && OnTheMap( point ) ? point : null;
_ = AfterFade( player, wake, "Vous vous réveillez à l'hôpital. On vous a sauvé de justesse.", "info", respawn: wake is null );
}
/// <summary>
/// Un point des réglages qui a encore un sol sous lui. Ceux posés sur Rp Union City restent en
/// base après le passage à Truenorth (30/10/2026) : sans ce contrôle, on réapparaissait dans le
/// vide. Ignoré, il laisse la place aux points d'apparition de la carte.
/// </summary>
bool OnTheMap( ServerSettings.Point point )
{
var from = point.Position + Vector3.Up * 80;
return Scene.Trace.Ray( from, from + Vector3.Down * 600 ).WithoutTags( "player", "trigger" ).Run().Hit;
}
/// <summary>Fondu au noir chez le joueur, puis (sous le noir) le déplacement, les soins complets et le message.</summary>
async Task AfterFade( RoleplayPlayer player, ServerSettings.Point to, string message, string kind, bool respawn )
{
// Caché des autres dès maintenant (il se relève sur place sous le fondu), et jusqu'à ce que
// le déplacement leur soit parvenu (RoleplayPlayer.Evacuating).
player.Evacuating = true;
PlayFade( player.SteamId );
await GameTask.DelaySeconds( 1.1f );
if ( !player.IsValid() )
return;
if ( to is not null )
player.TeleportTo( to.Position );
else if ( respawn )
player.TeleportTo( FindSpawn().Position );
player.Health = 100;
player.Hunger = 100;
player.Notify( message, kind );
await GameTask.DelaySeconds( 0.7f );
if ( player.IsValid() )
player.Evacuating = false;
}
/// <summary>Chez tous : le son d'une réanimation (Pain.Revived).</summary>
[Rpc.Broadcast( NetFlags.HostOnly )]
void PlayRevived( Vector3 body, int sound ) => Pain.Revived( body, sound );
/// <summary>Chez le joueur concerné : le fondu au noir (FadeEffect).</summary>
[Rpc.Broadcast]
void PlayFade( ulong steamId )
{
if ( RoleplayPlayer.Local?.SteamId == steamId )
FadeEffect.Trigger();
}
/// <summary>
/// Un EMS (ou le staff, si le réglage le permet) a maintenu E le temps voulu sur un inconscient.
/// L'hôte revérifie : le métier, la distance au corps, l'état du blessé.
/// </summary>
[Rpc.Host]
public void RequestRevive( Guid targetObject )
{
var rescuer = RoleplayPlayer.All.FirstOrDefault( x => x.Network.Owner == Rpc.Caller );
var target = Scene.Directory.FindByGuid( targetObject )?.GetComponent<RoleplayPlayer>();
if ( rescuer is null || target is null || rescuer == target || rescuer.Downed || !target.Downed )
return;
if ( !RoleplayPlayer.CanRevive( rescuer ) )
{
rescuer.Notify( rescuer.JobId == EmsRules.JobId ? "Prenez votre service au vestiaire de l'hôpital pour réanimer." : "Seuls les secouristes en service peuvent réanimer.", "erreur" );
return;
}
if ( rescuer.WorldPosition.Distance( target.BodyPosition ) > 160 )
return;
Revive( target, rescuer );
}
}
/// <summary>Chez soi : le fondu au noir d'une réanimation ou d'une mort (HorizonHud).</summary>
public static class FadeEffect
{
static RealTimeSince since = 100;
/// <summary>Le noir, de 0 à 1 : il tombe en 0,6 s, tient 1 s, se lève en 1,4 s.</summary>
public static float Amount
{
get
{
var t = (float)since;
if ( t < 0.6f ) return t / 0.6f;
if ( t < 1.6f ) return 1;
return MathF.Max( 0, 1 - (t - 1.6f) / 1.4f );
}
}
public static void Trigger() => since = 0;
}