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.
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" );
}
}