Effects/BlastEffect.cs

Static utility that spawns a visual explosion effect and flash light. It clones a prefab, strips any RadiusDamage components from the cloned scene (to avoid engine-supplied damage/physics impulses), scales and enables the visual prefab, and creates a temporary point light; it also optionally notifies the network about the effect.

NetworkingFile AccessNative Interop
using Sandbox;

namespace NZombies;

/// <summary>
/// EFFECTS/BLAST — the visible half of an explosion, and nothing else.
///
/// ⛔ IT EXISTS TO STRIP `Sandbox.RadiusDamage` OFF THE ENGINE PREFAB, AND THAT IS A REAL BUG
/// FIXED. `prefabs/engine/explosion_med.prefab` is the only explosion prefab in the asset
/// system, so both the grenade and PhD Flopper clone it — and it ships with a `RadiusDamage`
/// component set to `DamageAmount: 100`, `Radius: 256`, `PhysicsForceScale: 1` and
/// `DamageOnEnabled: true`. Cloning it enabled therefore did three things nobody asked for:
///
/// - **shoved the player**, which is what got it reported
/// - dealt a flat **100 damage** in 256u on top of the caller's own calculation
/// - ignored every multiplier the caller had just worked out, so PhD's M1 ×3 was diluted and
///   the log line reporting the damage was wrong
///
/// Both callers already do their own damage through `Health.OnDamage` — `Grenade` at its falloff
/// loop, `PhdAugments.Detonate` at its radius check — so the engine component was never wanted
/// by either. It was surplus in the grenade for as long as the grenade has existed.
///
/// ⚠️ CLONED DISABLED, STRIPPED, THEN ENABLED, AND THE ORDER IS THE WHOLE POINT.
/// `DamageOnEnabled: true` means the component fires the moment it is enabled — so cloning with
/// `StartEnabled = true` and disabling afterwards would be too late by a frame. There is no
/// "disable it quickly enough"; it has to never be enabled.
///
/// ⚠️ ONE HOME FOR THIS, not a copy in each caller. Two callers stripping the same component two
/// ways is the §3 shape, and the copy that would get missed is whichever explosion is added next.
/// </summary>
public static class BlastEffect
{
	/// <summary>
	/// The engine's medium explosion — the only one that ships.
	///
	/// ⚠️ Searching the whole asset system returns exactly one explosion prefab, which is why
	/// both callers share it rather than authoring their own.
	/// </summary>
	public static string Prefab { get; set; } = "prefabs/engine/explosion_med.prefab";

	/// <summary>
	/// The radius the prefab's own particle is authored for. Scaling is measured against it.
	/// </summary>
	public const float AuthoredRadius = 256f;

	/// <summary>
	/// Spawn the particle and the flash at a position, sized to <paramref name="radius"/>.
	///
	/// ⚠️ A LIGHT AS WELL AS THE PARTICLE. Most of a blast's impact in a dark map is the flash
	/// on the walls; a fireball that leaves the geometry unlit reads as a sprite pasted over the
	/// scene. That note comes from `Grenade.Effect`, which is where this light was written.
	///
	/// ⚠️ MISSING ASSET = NO EFFECT AND NO EXCEPTION. Decoration must not be able to break a
	/// gameplay path, and an asset path is exactly the kind of thing that fails at LOAD time
	/// with a green compile.
	/// </summary>
	public static void Spawn( Vector3 at, float radius, bool announce = true )
	{
		// ⚠️ EVERY MACHINE BUILDS ITS OWN. Grenades, PhD's dive blast and Chain Reaction all come
		// through here, and none of them has ever been seen by anybody but the player who caused it.
		if ( announce && Networking.IsActive && Connection.Local is not null )
			NZNet.WorldFx( Connection.Local.Id.ToString(), "",
				(int)NZNet.FxKind.Blast, at, System.Guid.Empty, radius );

		var scene = Game.ActiveScene;
		if ( !scene.IsValid() ) return;

		if ( ResourceLibrary.Get<PrefabFile>( Prefab ) is { } file
			&& SceneUtility.GetPrefabScene( file ) is { } prefabScene )
		{
			// ⚠️ `StartEnabled = false` — see the class note. The strip below only works because
			// nothing in here has been enabled yet.
			var go = prefabScene.Clone( new CloneConfig
			{
				Name = "nz_blast_fx",
				StartEnabled = false,
				Transform = new()
				{
					Position = at,
					Scale = radius / AuthoredRadius,
				},
			} );

			if ( go.IsValid() )
			{
				Strip( go );

				go.NetworkMode = NetworkMode.Never;
				go.Enabled = true;
			}
		}

		var lightGO = new GameObject( true, "nz_blast_flash" );
		lightGO.WorldPosition = at;
		lightGO.NetworkMode = NetworkMode.Never;

		var light = lightGO.Components.Create<PointLight>();
		light.LightColor = new Color( 1f, 0.75f, 0.35f ) * 12f;
		light.Radius = radius * 2.5f;
		light.Shadows = false;

		// ⚠️ CALLED STATICALLY. `using SWB.Shared` would import the extension but also a second
		// `DamageInfo`, making every damage call in a file ambiguous — the narrower fix is to
		// name the class. That note is copied from `Grenade` because the trap is the same here.
		SWB.Shared.GameObjectExtensions.DestroyAsync( lightGO, 0.12f );
	}

	/// <summary>
	/// Remove every damage-dealing component from a freshly cloned, still-disabled effect.
	///
	/// ⚠️ `EverythingInSelfAndDescendants`, because the component sits on a child of the prefab
	/// root, not on the root — a plain `Get` finds nothing and the strip silently does nothing,
	/// which would look exactly like the fix not working.
	///
	/// ⚠️ DESTROYED, NOT DISABLED. A disabled `RadiusDamage` with `DamageOnEnabled` is one
	/// stray `Enabled = true` away from firing, and prefab children get enabled by all sorts of
	/// things. Gone is gone.
	/// </summary>
	static void Strip( GameObject go )
	{
		var found = 0;

		foreach ( var rd in go.Components
			.GetAll<RadiusDamage>( FindMode.EverythingInSelfAndDescendants ) )
		{
			rd.Destroy();
			found++;
		}

		// ⚠️ WARNS IF IT FINDS NOTHING, because "nothing to strip" and "the lookup is wrong"
		// are the same silence. If the engine prefab ever stops shipping RadiusDamage this
		// should go, and the warning is what will say so.
		if ( found == 0 )
			Log.Warning( "[nz-blast] no RadiusDamage on the explosion prefab —"
				+ " either the engine changed it, or this lookup is wrong" );
	}
}