Weapons/Projectile.cs

Host-authored projectile component for grenades, 40 mm shells, rockets and mines. The host simulates flight, fuses, bounce physics and mine triggers, spawns the projectile over the network, and calls WeaponSystem.Detonate to resolve explosions; clients get trails, looping sounds and bounce/plant sounds.

Networking
namespace HorizonRP;

/// <summary>
/// Un explosif en route ou posé : grenade lancée, obus de 40 mm, roquette, mine. Créé et mené par
/// l'hôte (WeaponSystem.RequestLaunch), vu de tous par le réseau ; c'est l'hôte qui décide de
/// l'explosion (WeaponSystem.Detonate). Chaque machine y ajoute ce qui se voit et s'entend : la
/// traînée d'une roquette, le sifflement de son moteur.
/// </summary>
public sealed class Projectile : Component, Component.ICollisionListener
{
	[Sync( SyncFlags.FromHost )] public string KindId { get; set; }

	/// <summary>Hôte : le lanceur (l'auteur des dégâts), et la direction d'une mine posée.</summary>
	public RoleplayPlayer Launcher { get; set; }
	Vector3 velocity;
	Vector3 facing;
	RealTimeSince since;
	bool exploded;
	SoundHandle loop;
	RealTimeSince sinceBounce = 10;

	Explosives.Kind Kind => Explosives.Get( KindId );

	/// <summary>Hôte : lance (ou pose) un explosif.</summary>
	public static void Spawn( Explosives.Kind k, Vector3 position, Vector3 direction, RoleplayPlayer by )
	{
		var go = new GameObject( true, $"Explosif {k.Id}" );
		var rotation = Rotation.LookAt( direction );
		if ( k.Mode == "mine" )
		{
			// Posée au sol, un pas devant soi, tournée vers où l'on regarde.
			var flat = direction.WithZ( 0 ).Normal;
			var ground = Game.ActiveScene.Trace.Ray( position + flat * 40, position + flat * 40 + Vector3.Down * 200 )
				.IgnoreGameObjectHierarchy( by?.GameObject ).WithoutTags( "player", "trigger" ).Run();
			position = ground.Hit ? ground.HitPosition : position + flat * 40 + Vector3.Down * 60;
			rotation = Rotation.LookAt( flat );
			direction = flat;
		}
		go.WorldPosition = position;
		go.WorldRotation = rotation;
		var renderer = go.Components.Create<ModelRenderer>();
		renderer.Model = Model.Load( k.Model );
		var p = go.Components.Create<Projectile>();
		p.KindId = k.Id;
		p.Launcher = by;
		p.facing = direction;
		p.velocity = direction * k.Speed;

		if ( k.Mode == "lance" )
		{
			// Lancée à la main : un peu vers le haut, qui tourne, rebondit et roule.
			var collider = go.Components.Create<SphereCollider>();
			collider.Radius = 2.5f;
			var body = go.Components.Create<Rigidbody>();
			body.MassOverride = 0.5f;
			// En l'air, à peine freinée ; chaque rebond lui prend de la vitesse (OnCollisionStart) et,
			// une fois au sol, elle s'arrête vite au lieu de rouler sans fin (Julien).
			body.LinearDamping = 0.05f;
			body.AngularDamping = 0.5f;
			body.Velocity = direction * k.Speed + Vector3.Up * 160 + (by?.GetComponent<PlayerController>()?.Velocity ?? Vector3.Zero);
			body.AngularVelocity = new Vector3( Game.Random.Float( -12, 12 ), Game.Random.Float( -12, 12 ), Game.Random.Float( -12, 12 ) );
		}
		go.NetworkSpawn();
		if ( k.Mode == "mine" )
			p.PlayPlant();
	}

	// Un RealTimeSince par défaut compte depuis le lancement du jeu : la mèche était « dépassée »
	// dès la création, et la roquette explosait à la bouche du tube.
	protected override void OnAwake() => since = 0;

	protected override void OnStart()
	{
		if ( Application.IsDedicatedServer || Kind is not { } k )
			return;
		if ( k.Loop is not null )
			loop = Sound.Play( k.Loop, WorldPosition );
		if ( k.Mode is "roquette" or "obus" )
			Trail( k.Mode == "roquette" );
	}

	/// <summary>La traînée de fumée d'une roquette (épaisse) ou d'un obus (légère).</summary>
	void Trail( bool rocket )
	{
		var go = new GameObject( GameObject, true, "Traînée" );
		go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
		var fx = go.Components.Create<ParticleEffect>();
		fx.MaxParticles = 300;
		fx.Lifetime = rocket ? 2.2f : 0.8f;
		fx.Space = ParticleEffect.SimulationSpace.World;
		fx.ApplyShape = true;
		fx.Scale = rocket ? 20f : 8f;
		fx.ApplyColor = true;
		fx.ApplyAlpha = true;
		fx.Alpha = rocket ? 0.55f : 0.35f;
		fx.ConstantMovement = new Vector3( 0, 0, 12 );
		var emitter = go.Components.Create<ParticleSphereEmitter>();
		emitter.Loop = true;
		emitter.Radius = 1.5f;
		emitter.Rate = rocket ? 70f : 35f;
		var sprite = go.Components.Create<ParticleSpriteRenderer>();
		sprite.Sprite = Sprite.FromTexture( Texture.Load( "materials/horizon/fumee.png" ) );
		sprite.Lighting = true;
		if ( rocket )
		{
			var flame = new GameObject( GameObject, true, "Flamme" );
			flame.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
			flame.LocalPosition = Vector3.Backward * 14;
			var light = flame.Components.Create<PointLight>();
			light.LightColor = new Color( 1f, 0.55f, 0.15f ) * 3f;
			light.Radius = 160;
			light.Shadows = false;
		}
	}

	protected override void OnUpdate()
	{
		if ( loop is not null && loop.IsValid() )
			loop.Position = WorldPosition;
	}

	protected override void OnFixedUpdate()
	{
		if ( IsProxy || exploded || Kind is not { } k )
			return;
		var dt = Time.Delta;
		switch ( k.Mode )
		{
			case "roquette":
			case "obus":
				if ( k.Mode == "obus" )
					velocity += Scene.PhysicsWorld.Gravity * dt;
				var from = WorldPosition;
				var to = from + velocity * dt;
				var tr = Scene.Trace.Ray( from, to ).UseHitboxes()
					.IgnoreGameObjectHierarchy( GameObject ).IgnoreGameObjectHierarchy( Launcher?.GameObject ).WithoutTags( "trigger" ).Run();
				// Les véhicules : leur vraie tôle, pas leur boîte (comme les balles).
				tr = Vehicle.RefineBulletTrace( tr, Launcher?.GameObject, from, to, 0, new[] { "trigger" } );
				if ( tr.Hit && since > 0.05f )
				{
					Explode( tr.HitPosition + tr.Normal * 4 );
					return;
				}
				WorldPosition = to;
				if ( velocity.Length > 1 )
					WorldRotation = Rotation.LookAt( velocity );
				if ( since > k.Fuse )
					Explode( WorldPosition );
				break;

			case "lance":
				if ( since > k.Fuse )
					Explode( WorldPosition + Vector3.Up * 4 );
				break;

			case "mine":
				if ( since > k.Fuse )
				{
					GameObject.Destroy();   // oubliée : retirée sans exploser
					return;
				}
				if ( since > 2f && Triggered() )
					Explode( WorldPosition + Vector3.Up * 8 );
				break;
		}
	}

	/// <summary>Une mine armée : quelqu'un d'autre que celui qui l'a posée, devant elle, à moins de 3,5 m, en vue.</summary>
	bool Triggered()
	{
		foreach ( var p in RoleplayPlayer.All )
		{
			if ( !p.IsValid() || p == Launcher || p.Health <= 0 )
				continue;
			var to = p.WorldPosition + Vector3.Up * 20 - WorldPosition;
			if ( to.Length > 140 || Vector3.Dot( to.Normal, facing ) < 0.5f )
				continue;
			var sight = Scene.Trace.Ray( WorldPosition + Vector3.Up * 8, p.WorldPosition + Vector3.Up * 20 )
				.IgnoreGameObjectHierarchy( GameObject ).IgnoreGameObjectHierarchy( p.GameObject ).WithoutTags( "trigger" ).Run();
			if ( !sight.Hit )
				return true;
		}
		return false;
	}

	void Explode( Vector3 at )
	{
		if ( exploded )
			return;
		exploded = true;
		WeaponSystem.Current?.Detonate( Kind, at, Launcher, Kind.Mode == "mine" ? facing : Vector3.Zero );
		GameObject.Destroy();
	}

	public void OnCollisionStart( Collision collision )
	{
		if ( Kind?.Mode != "lance" )
			return;
		// Hôte : le choc absorbe une bonne part de l'élan, et un rebond sur un sol presque plat
		// la freine pour de bon (une grenade ne roule que d'un ou deux mètres).
		if ( !IsProxy && Components.Get<Rigidbody>() is { } body )
		{
			body.Velocity *= GroundBounce;
			body.AngularVelocity *= 0.4f;
			if ( MathF.Abs( collision.Contact.Normal.z ) > 0.6f )
			{
				body.LinearDamping = GroundDamping;
				body.AngularDamping = 6f;
			}
		}
		if ( sinceBounce < 0.15f || collision.Contact.NormalSpeed < 60 )
			return;
		sinceBounce = 0;
		if ( !IsProxy )
			PlayBounce();
	}

	/// <summary>La vitesse gardée à chaque choc, et le freinage une fois au sol (grenades à main).</summary>
	public static float GroundBounce { get; set; } = 0.45f;
	public static float GroundDamping { get; set; } = 2.5f;

	[Rpc.Broadcast]
	void PlayBounce()
	{
		if ( Application.IsDedicatedServer )
			return;
		Sounds.PlayAt( "porte", WorldPosition, 0.35f );
	}

	[Rpc.Broadcast]
	void PlayPlant()
	{
		if ( Application.IsDedicatedServer )
			return;
		Sound.Play( "weapons/fas2/sounds/events/fas2_claymore_claymore_plant.wav.sound", WorldPosition );
	}

	protected override void OnDestroy()
	{
		loop?.Stop( 0.1f );
		loop = null;
	}
}