Death/HorizonGame.Death.cs

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.

Networking
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;
}