Death/HorizonGame.RunOver.cs

Host-side handler for pedestrians being hit by vehicles in the HorizonRP gamemode. Validates the RPC, computes damage and throw velocity from impact speed, logs the event, and either downs the victim or knocks them over for a few seconds. Broadcasts the ragdoll impulse and blood splatter to all clients.

Networking
namespace HorizonRP;

/// <summary>
/// Hôte : un piéton fauché par un véhicule (détecté par Vehicle.Pedestrians.cs). La vitesse du
/// choc décide de tout :
/// - les dégâts, qui croissent plus vite que la vitesse : ~10 à 20 km/h, ~25 à 30, ~63 à 50,
///   ~110 à 70. Un joueur en pleine forme survit donc à 50 km/h, et tombe inconscient au-delà ;
///   un joueur déjà blessé tombe plus tôt ;
/// - la projection : le corps part avec la vitesse de la voiture et monte d'autant plus haut
///   qu'elle allait vite, en tournant sur lui-même (les jambes fauchées, le buste qui bascule
///   sur le capot) ;
/// - à terre : inconscient si la vie tombe à zéro (Down, le système de mort), sinon simplement
///   renversé (<see cref="RoleplayPlayer.Knocked"/>) quelques secondes, puis il se relève là où
///   il est retombé.
/// </summary>
public sealed partial class HorizonGame
{
	readonly Dictionary<RoleplayPlayer, RealTimeSince> lastRunOver = new();

	/// <summary>Les dégâts d'un choc à <paramref name="kmh"/>.</summary>
	public static float RunOverDamage( float kmh )
	{
		var over = MathF.Max( 0, kmh - 10f );
		return 1.2f * over + 0.012f * over * over;
	}

	[Rpc.Host]
	public void RequestRunOver( Guid vehicleId, Guid targetId, Vector3 velocity )
	{
		var vehicle = Scene.Directory.FindByGuid( vehicleId )?.GetComponent<Vehicle>();
		var target = Scene.Directory.FindByGuid( targetId )?.GetComponent<RoleplayPlayer>();
		if ( !vehicle.IsValid() || !target.IsValid() || target.Downed || target.Knocked || target.NeedsCharacter || target.GodMode )
			return;
		if ( target.GameObject.GetComponentInParent<Vehicle>() is not null )
			return;

		// Seul celui qui simule le véhicule en parle : son conducteur, ou l'hôte s'il n'en a pas.
		var owner = vehicle.Network.Owner;
		if ( owner is null ? !Rpc.Caller.IsHost : owner != Rpc.Caller )
			return;
		if ( target.WorldPosition.Distance( vehicle.WorldPosition ) > 420f )
			return;
		if ( lastRunOver.TryGetValue( target, out var since ) && since < 1.5f )
			return;
		lastRunOver[target] = 0;

		// La vitesse annoncée, bornée par ce que ce modèle peut atteindre.
		var maxKmh = (vehicle.Info?.MaxSpeedKmh ?? 150f) * 1.25f;
		var kmh = MathF.Min( velocity.Length * Vehicle.ToKmh, maxKmh );
		if ( kmh < Vehicle.RunOverMinKmh )
			return;
		var speed = kmh / Vehicle.ToKmh;
		var dir = velocity.WithZ( 0 ).Length > 1 ? velocity.WithZ( 0 ).Normal : vehicle.WorldRotation.Forward;

		var damage = RunOverDamage( kmh );
		var driver = vehicle.Driver;
		var by = driver is null ? ", sans conducteur" : $", conduit par {driver.RpName} ({driver.SteamId})";

		WeaponSystem.Current?.Holster( target );
		target.Held = false;
		var before = target.Health;
		target.Health = MathF.Max( 0, target.Health - damage );

		// Chaque choc au journal : le véhicule, sa plaque, la vitesse, la vie perdue et ce qu'il en reste.
		var model = vehicle.Info?.Name ?? "véhicule";
		var outcome = target.Health <= 0 ? "inconscient" : $"reste {target.Health:0} de vie";
		AddLog( "renverse", target.SteamId,
			$"{target.RpName} renversé par {model} {vehicle.Plate} à {kmh:0} km/h{by} : {before - target.Health:0} de vie en moins, {outcome}" );

		// À petite vitesse on est bousculé et l'on tombe ; plus vite, on décolle et l'on vole.
		var fast = Math.Clamp( (kmh - 20f) / 60f, 0, 1 );
		var forward = dir * speed * (0.55f + 0.5f * fast) * Random.Shared.Float( 0.9f, 1.05f );
		var up = Vector3.Up * (70f + 430f * fast) * Random.Shared.Float( 0.85f, 1.1f );
		// La rotation : autour de l'axe horizontal perpendiculaire au choc (on bascule par-dessus),
		// un peu de vrille autour de la verticale, au hasard.
		var tumble = Vector3.Cross( dir, Vector3.Up ).Normal * (2f + 9f * fast) * (Random.Shared.Int( 0, 1 ) == 0 ? 1 : -1);
		var spin = tumble + Vector3.Up * Random.Shared.Float( -3f, 3f ) * fast;
		BroadcastThrow( target.GameObject.Id, forward + up, spin );
		if ( kmh >= 30f )
			BroadcastImpactBlood( vehicle.GameObject.Id, target.GameObject.Id, target.WorldPosition + Vector3.Up * 40, kmh );

		if ( target.Health <= 0 )
		{
			Down( target, $"renversé par {model} {vehicle.Plate} à {kmh:0} km/h", driver );
			return;
		}

		target.Knocked = true;
		var serial = ++target.KnockSerial;
		_ = EndKnock( target, serial, Math.Clamp( 2.4f + kmh / 35f, 2.6f, 5.5f ) );
	}

	/// <summary>La fin du renversement : il se relève (si un autre choc ne l'a pas relancé entre-temps).</summary>
	async Task EndKnock( RoleplayPlayer player, int serial, float seconds )
	{
		await GameTask.DelaySeconds( seconds );
		if ( player.IsValid() && player.Knocked && player.KnockSerial == serial )
			player.Knocked = false;
	}

	/// <summary>
	/// Chez tous, un choc violent : une giclée au sol là où il a été frappé, et une sur la
	/// carrosserie, à l'endroit touché (attachée au véhicule, elle le suit). Plus gros plus vite.
	/// </summary>
	[Rpc.Broadcast( NetFlags.HostOnly )]
	void BroadcastImpactBlood( Guid vehicleId, Guid targetId, Vector3 chest, float kmh )
	{
		var size = Math.Clamp( 10f + kmh * 0.25f, 18f, 32f );
		var victim = Scene.Directory.FindByGuid( targetId );
		Blood.OnGround( Blood.Kind.Splat, Scene, chest, size, ignore: victim );
		var car = Scene.Directory.FindByGuid( vehicleId )?.GetComponent<Vehicle>();
		if ( !car.IsValid() || car.Rig is null )
			return;
		// Le buste est déjà dans la boîte de la caisse au moment du choc : un rayon parti de lui
		// ne la touchait pas. On part d'un peu plus loin, du même côté, vers le centre.
		var center = car.WorldTransform.PointToWorld( car.Rig.BodyBounds.Center );
		var away = (chest - center).WithZ( 0 ).Normal;
		var from = chest + away * 80f;
		// Et sans le piéton : sa capsule, entre les deux, arrêtait le rayon.
		var trace = Scene.Trace.Ray( from, center.WithZ( chest.z - 8f ) ).WithoutTags( "player", "ragdoll", "trigger" );
		if ( victim.IsValid() )
			trace = trace.IgnoreGameObjectHierarchy( victim );
		var tr = trace.Run();
		if ( tr.Hit && tr.GameObject?.GetComponentInParent<Vehicle>() == car )
			Blood.Place( Blood.Kind.Splat, tr.HitPosition, tr.Normal, size * 0.85f, parent: car.GameObject );
	}

	/// <summary>Chez tous : l'élan du corps, appliqué au ragdoll dès qu'il existe (RoleplayPlayer.Death.cs).</summary>
	[Rpc.Broadcast( NetFlags.HostOnly )]
	void BroadcastThrow( Guid playerId, Vector3 velocity, Vector3 spin )
	{
		Scene.Directory.FindByGuid( playerId )?.GetComponent<RoleplayPlayer>()?.QueueThrow( velocity, spin );
	}
}