Partial WeaponSystem class for explosives in an RP gamemode. Host validates an RPC launch request and spawns a projectile, detonates blasts that damage players and vehicles with falloff and cover checks, and broadcasts sounds and effects (flash, smoke, explosion, scorch mark). Also holds the smoke cloud spawner and the local flash-blind effect.
using SWB.Shared;
namespace HorizonRP;
/// <summary>
/// Les explosifs côté hôte : lancer (RequestLaunch), faire exploser (Detonate), blesser
/// (HostDamage) ; et chez tous, ce qui se voit et s'entend (PlayExplosion).
/// </summary>
public sealed partial class WeaponSystem
{
readonly Dictionary<ulong, RealTimeSince> lastLaunch = new();
/// <summary>
/// Chez le tireur, l'arme a « tiré » un explosif (ExplosiveBulletInfo). L'hôte vérifie que
/// c'est bien l'arme sortie, que la cadence est tenable et que le départ est à ses yeux.
/// </summary>
[Rpc.Host]
public void RequestLaunch( string kindId, Vector3 eye, Vector3 direction )
{
var shooter = Caller();
var entry = HolderOf( shooter )?.ActiveEntry;
var k = Explosives.Get( kindId );
if ( shooter is null || entry is null || k is null || entry.Id != kindId || !drawn.ContainsKey( shooter.SteamId ) )
return;
if ( lastLaunch.TryGetValue( shooter.SteamId, out var last ) && last < 0.6f )
return;
var realEye = shooter.GetComponent<PlayerController>()?.EyePosition ?? shooter.WorldPosition + Vector3.Up * 64;
if ( eye.Distance( realEye ) > 80 )
eye = realEye;
lastLaunch[shooter.SteamId] = 0;
var dir = direction.Normal;
Projectile.Spawn( k, eye + dir * 24, dir, shooter );
HorizonGame.Current?.AddLog( "explosif", shooter.SteamId, $"{k.Name} {(k.Mode == "mine" ? "posée" : "lancée")} par {shooter.RpName}" );
// Une grenade ou une mine lancée n'est plus dans la main. S'il en reste dans l'inventaire,
// l'arme se recharge d'elle-même (rechargement automatique de SWB) ; sinon c'était la
// dernière : elle quitte l'inventaire, la main se vide (après le geste du lancer).
if ( k.Mode is "lance" or "mine" && !shooter.Inventory.Any( x => x.Item == entry.Ammo && x.Count > 0 ) )
_ = ThrowLast( shooter, HolderOf( shooter )?.ActiveSerial );
}
async Task ThrowLast( RoleplayPlayer player, string serial )
{
await GameTask.Delay( 450 );
if ( !player.IsValid() || serial is null || HolderOf( player )?.ActiveSerial != serial )
return;
Holster( player );
if ( player.Inventory.FirstOrDefault( x => WeaponData.Read( x.Data )?.Serial == serial ) is { } slot )
player.RemoveFrom( slot, slot.Count );
}
/// <summary>
/// Hôte : l'explosion. Le souffle blesse les joueurs (moins derrière un mur, moins à bord d'un
/// véhicule) et abîme les véhicules ; une mine dirigée frappe surtout devant elle (<paramref name="facing"/>).
/// </summary>
public void Detonate( Explosives.Kind k, Vector3 position, RoleplayPlayer by, Vector3 facing )
{
if ( k is null )
return;
PlayExplosion( k.Id, position );
if ( k.Effect != "souffle" )
return;
foreach ( var p in RoleplayPlayer.All.ToList() )
{
if ( !p.IsValid() || p.Health <= 0 )
continue;
var chest = p.WorldPosition + Vector3.Up * 38;
var d = position.Distance( chest );
if ( d > k.Outer )
continue;
var damage = Explosives.BlastDamage( k, d );
if ( facing != Vector3.Zero && Vector3.Dot( (chest - position).Normal, facing ) < 0.35f )
damage *= 0.15f;
var aboard = p.GameObject.GetComponentInParent<Vehicle>();
var sightTrace = Scene.Trace.Ray( position + Vector3.Up * 4, chest )
.IgnoreGameObjectHierarchy( p.GameObject ).WithoutTags( "player", "trigger" );
if ( aboard.IsValid() )
sightTrace = sightTrace.IgnoreGameObject( aboard.GameObject );
if ( sightTrace.Run().Hit )
damage *= 0.3f; // à couvert
if ( aboard.IsValid() )
damage *= 0.5f; // la caisse protège un peu
if ( damage >= 1 )
HostDamage( p, damage, by, $"explosion {(k.Id == "vehicule" ? "d'un véhicule" : $"de {k.Name}")} à {d * 0.0254f:0} m" );
}
foreach ( var car in Vehicle.All.ToList() )
{
if ( !car.IsValid() || !car.Ready || car.Rig is null )
continue;
var center = car.WorldTransform.PointToWorld( car.Rig.Bounds.Center );
var d = MathF.Max( 0, center.Distance( position ) - car.Rig.Bounds.Size.Length * 0.3f );
var f = Explosives.Falloff( d, k.Outer );
if ( facing != Vector3.Zero && Vector3.Dot( (center - position).Normal, facing ) < 0.35f )
f *= 0.2f;
if ( f > 0.02f )
car.TakeBlast( position, f, k.VehiclePower );
}
}
/// <summary>Hôte : des points de vie en moins ; à zéro, inconscient (HorizonGame.Down).</summary>
public void HostDamage( RoleplayPlayer target, float damage, RoleplayPlayer by, string cause )
{
if ( target is null || target.Health <= 0 || target.GodMode )
return;
target.Health = MathF.Max( 0, target.Health - damage );
if ( target.Health > 0 )
return;
var auteur = by is null ? "" : $" par {by.RpName} ({by.SteamId})";
HorizonGame.Current?.AddLog( "explose", target.SteamId, $"{target.RpName} tué : {cause}{auteur} ({damage:0} de dégâts)" );
HorizonGame.Current?.Down( target, cause, by );
}
/// <summary>Partout : le bruit, la lumière et la fumée d'une explosion ; le flash d'une M84 ; un nuage de fumigène.</summary>
[Rpc.Broadcast]
void PlayExplosion( string kindId, Vector3 position )
{
if ( Application.IsDedicatedServer || Explosives.Get( kindId ) is not { } k )
return;
if ( k.Sounds.Length > 0 )
Sound.Play( k.Sounds[Game.Random.Int( 0, k.Sounds.Length - 1 )], position );
if ( k.Distant is not null )
Sound.Play( k.Distant, position );
var local = RoleplayPlayer.Local;
var eye = local?.GetComponent<PlayerController>()?.EyePosition ?? Vector3.Zero;
var distance = local is null ? float.MaxValue : eye.Distance( position );
switch ( k.Effect )
{
case "fumee":
SmokeCloud.Spawn( position, 32f );
break;
case "flash":
Flash( position, 0.25f );
if ( local is not null && distance < k.Outer )
{
var sight = Scene.Trace.Ray( position + Vector3.Up * 4, eye ).IgnoreGameObjectHierarchy( local.GameObject ).WithoutTags( "player", "trigger" ).Run();
if ( !sight.Hit )
{
// Face au flash : aveuglé ; de dos, surtout assourdi.
var facing = Vector3.Dot( Scene.Camera.WorldRotation.Forward, (position - eye).Normal );
var strength = Explosives.Falloff( distance, k.Outer ) * MathX.Lerp( 0.35f, 1f, MathX.Clamp( (facing + 0.2f) / 1.2f, 0, 1 ) );
FlashEffect.Trigger( MathX.Clamp( strength * 1.4f, 0, 1 ) );
}
}
break;
default:
Flash( position, 1f );
SpawnBlastEffect( position );
break;
}
if ( local is not null && distance < k.Outer * 1.6f && k.Effect != "fumee" )
CameraShake.Add( Explosives.Falloff( distance, k.Outer * 1.6f ) * (k.Effect == "flash" ? 0.4f : 1.1f) );
}
/// <summary>Une lumière d'un instant, qui s'éteint en un tiers de seconde.</summary>
void Flash( Vector3 position, float strength )
{
var go = new GameObject( true, "Éclair d'explosion" );
go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
go.WorldPosition = position + Vector3.Up * 20;
var light = go.Components.Create<PointLight>();
light.LightColor = new Color( 1f, 0.7f, 0.4f ) * 8f * strength;
light.Radius = 900 * MathF.Max( 0.4f, strength );
light.Shadows = false;
go.DestroyAsync( 0.35f );
}
/// <summary>Le feu, la fumée et les étincelles d'explosion de s&box, sans ses dégâts de zone (les nôtres sont comptés par l'hôte), et une trace brûlée au sol.</summary>
void SpawnBlastEffect( Vector3 position )
{
var fx = GameObject.Clone( "prefabs/engine/explosion_med.prefab", new Transform( position ), startEnabled: false );
if ( fx.IsValid() )
{
fx.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
fx.NetworkMode = NetworkMode.Never;
foreach ( var c in fx.Components.GetAll<Component>( FindMode.EverythingInSelfAndDescendants ).Where( c => c.GetType().Name == "RadiusDamage" ).ToList() )
c.Destroy();
fx.WorldScale = 1.6f;
fx.Enabled = true;
}
var ground = Scene.Trace.Ray( position + Vector3.Up * 10, position + Vector3.Down * 80 ).WithoutTags( "player", "trigger" ).Run();
if ( ground.Hit )
{
var scorch = GameObject.Clone( "prefabs/engine/scorch.prefab", new Transform( ground.HitPosition + ground.Normal, Rotation.LookAt( -ground.Normal ) ) );
if ( scorch.IsValid() )
{
scorch.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
scorch.NetworkMode = NetworkMode.Never;
foreach ( var c in scorch.Components.GetAll<Component>( FindMode.EverythingInSelfAndDescendants ).Where( c => c.GetType().Name == "TemporaryEffect" ).ToList() )
c.Destroy();
scorch.WorldScale = 2.5f;
scorch.DestroyAsync( 120f );
}
}
}
}
/// <summary>Le nuage d'un fumigène : une fumée épaisse qui monte, s'étale et finit par se dissiper.</summary>
public static class SmokeCloud
{
public static void Spawn( Vector3 position, float seconds )
{
var go = new GameObject( true, "Fumigène" );
go.Flags |= GameObjectFlags.NotNetworked | GameObjectFlags.NotSaved;
go.WorldPosition = position + Vector3.Up * 10;
var fx = go.Components.Create<ParticleEffect>();
fx.MaxParticles = 500;
fx.Lifetime = 9f;
fx.StartVelocity = 30f;
fx.Damping = 0.8f;
fx.ConstantMovement = new Vector3( 0, 0, 8 );
fx.Space = ParticleEffect.SimulationSpace.World;
fx.ApplyShape = true;
fx.Scale = 110f;
fx.ApplyColor = true;
fx.Tint = new Color( 0.82f, 0.83f, 0.85f );
fx.ApplyAlpha = true;
fx.Alpha = 0.92f;
var emitter = go.Components.Create<ParticleSphereEmitter>();
emitter.Loop = true;
emitter.Radius = 70f;
emitter.Rate = 26f;
var sprite = go.Components.Create<ParticleSpriteRenderer>();
sprite.Sprite = Sprite.FromTexture( Texture.Load( "materials/horizon/fumee.png" ) );
sprite.Lighting = true;
_ = StopLater( go, emitter, seconds );
}
static async Task StopLater( GameObject go, ParticleSphereEmitter emitter, float seconds )
{
await GameTask.DelaySeconds( MathF.Max( 1, seconds - 9 ) );
if ( emitter.IsValid() )
emitter.Enabled = false;
await GameTask.DelaySeconds( 10 );
if ( go.IsValid() )
go.Destroy();
}
}
/// <summary>
/// Chez soi : l'éblouissement d'une grenade assourdissante. L'écran blanchit d'un coup puis se
/// rouvre en quelques secondes (HorizonHud), et un sifflement aigu couvre tout.
/// </summary>
public static class FlashEffect
{
static float peak;
static RealTimeSince since = 100;
/// <summary>Le voile blanc, de 0 à 1.</summary>
public static float Amount
{
get
{
var hold = peak * 1.2f; // ébloui un moment, puis l'œil se rouvre
var t = since - hold;
return t <= 0 ? peak : MathF.Max( 0, peak * (1 - t / 3.5f) );
}
}
public static void Trigger( float strength )
{
if ( strength <= 0.05f )
return;
peak = MathF.Max( Amount, strength );
since = 0;
Sounds.Play( "acouphene", 0.5f * strength );
}
}