A visual effect component that draws a floor vortex using LineRenderer lines. It samples the floor height on a polar grid at spawn, creates rim, core, spiral arms and collapsing rings, animates rotation, fading and a point light, and can be spawned locally or announced to other clients.
using Sandbox;
using System;
using System.Collections.Generic;
using System.Linq;
namespace NZombies;
/// <summary>
/// A vortex painted on the floor — spiral arms winding into a hot core, for something that pulls.
///
/// ⛔ IT IS DRAWN AT THE RADIUS IT IS GIVEN AND NOTHING ELSE, which is the whole contract. Oberon's
/// hole drags anything inside `PullRadius`, so the rim ring IS that number: standing outside it has
/// to be safe, and a ring drawn generously is a lie the attack does not tell.
///
/// ⛔ AND IT IS `LineRenderer`s, NOT A PARTICLE SYSTEM OR A SHADER. The same reasoning as
/// `ShockRing`: a ring is a polyline with a radius, needing no texture, no material and nothing
/// extracted from a `.pcf` first. A spiral is the same polyline with the angle advancing as the
/// radius shrinks — so the entire effect is arithmetic this project can already draw.
///
/// ⚠️ FOUR LAYERS, AND EACH ONE IS DOING A JOB:
///
/// • THE RIM says where the reach ends. Static, bright, traced to the floor.
/// • THE ARMS say which way it turns, and are the only reason it reads as a vortex rather than
/// as a circle. They rotate DIFFERENTIALLY — the core end faster than the rim end — so the
/// spiral visibly tightens. That shear is what sells inward flow; arms that merely spin as a
/// rigid shape read as a wheel.
/// • THE COLLAPSING RINGS say it is pulling. They leave the rim and shrink into the core on a
/// stagger, which states the direction outright rather than implying it.
/// • THE CORE is a small bright ring with NOTHING inside it. Additive drawing cannot paint
/// black, so the hole is made of absence: a hot event horizon around an unlit middle.
///
/// ⚠️ THE ARMS FADE FROM DIM AT THE RIM TO HOT AT THE CORE, through a two-stop `Gradient` on each
/// line. That gradient is the cheapest thing in the file and does more for the read than any of
/// the geometry: it pulls the eye inward, which is the direction the attack moves you.
/// </summary>
public sealed class Vortex : Component
{
// ══ tuning ═══════════════════════════════════════════════════════════════
//
// ⛔ NULLABLE-BACKED GETTERS — a static's VALUE survives a hotload but its initialiser does not
// re-run. INSTRUCTIONS.md §1. The same pattern as `ShockRing` and `PitVisual`.
static int? _arms;
/// <summary>How many spiral arms. 5.</summary>
///
/// ⚠️ ODD ON PURPOSE. An even count puts an arm directly opposite every other arm, and from
/// above that reads as a set of straight bars crossing the middle rather than as a swirl.
public static int Arms { get => _arms ?? 5; set => _arms = value; }
static float? _turns;
/// <summary>How far an arm sweeps, in whole turns, from rim to core. 0.85.</summary>
///
/// ⚠️ UNDER ONE FULL TURN ON PURPOSE. At 1.3 the outer half of every arm runs almost
/// parallel to the rim, so five arms read as a set of concentric circles instead of as five
/// arms. Below a turn they stay diagonal all the way out and the count stays legible.
public static float Turns { get => _turns ?? 0.85f; set => _turns = value; }
static float? _tightness;
/// <summary>
/// How hard the arms bunch toward the core. 1.35. 1 is a plain Archimedean spiral.
///
/// ⚠️ THIS IS WHY THE SEGMENTS GO WHERE THEY ARE NEEDED. Radius falls as `(1-t)^tightness`, so
/// points crowd at the small end — which is exactly where a spiral turns fastest and where an
/// evenly-spaced polyline would visibly become a polygon.
/// </summary>
///
/// ⚠️ AND IT TRADES AGAINST `ArmSegments`, WHICH IS NOT OBVIOUS. Points are spaced evenly in
/// `t`, not in arc length, so crowding them at the core necessarily thins them at the rim —
/// where the circle is longest and a straight segment shows. At 1.7 with 40 points the first
/// segment spanned about 180 units of a 900-unit disc and the arms were visibly faceted.
public static float Tightness { get => _tightness ?? 1.35f; set => _tightness = value; }
static float? _spin;
/// <summary>Turns per second at the RIM. 0.3.</summary>
public static float Spin { get => _spin ?? 0.3f; set => _spin = value; }
static float? _shear;
/// <summary>
/// How much faster the core end turns than the rim end. 0.45.
///
/// ⛔ THE SINGLE MOST IMPORTANT NUMBER HERE. At 0 the arms are a rigid shape being rotated,
/// which the eye reads as a spinning wheel — the shape never changes, so nothing is flowing.
/// Any shear above zero makes the spiral wind tighter every frame, and a curve that is
/// continuously tightening is the whole visual language of "being sucked in".
/// </summary>
/// ⛔ AND IT IS BUDGETED AGAINST THE LIFETIME, WHICH IS THE WHOLE OF THE TUNING. The core
/// gains `Seconds × Spin × Shear` extra turns before the vortex closes — here 6 × 0.3 × 0.45,
/// or about **0.8 of a turn**. That is enough to watch it tighten and not enough to wind the
/// arms past each other. At 0.9 it was 1.9 turns and the last three seconds were a scribble:
/// the arms had wrapped so far they merged into a dense set of rings with no direction left in
/// them. Raise `Shear` for a longer attack only by lowering it for a shorter one.
public static float Shear { get => _shear ?? 0.45f; set => _shear = value; }
static int? _armSegments;
/// <summary>Points per arm. 56. See `Tightness` for why this is not lower.</summary>
public static int ArmSegments { get => _armSegments ?? 56; set => _armSegments = value; }
static int? _rimSegments;
/// <summary>Points in the rim and collapsing rings. 64.</summary>
///
/// ⚠️ HIGHER THAN `ShockRing`'S 32 BECAUSE IT COSTS NOTHING HERE. There it is 32 TRACES a
/// frame; here the floor is already measured and a point is arithmetic.
public static int RimSegments { get => _rimSegments ?? 64; set => _rimSegments = value; }
static int? _collapse;
/// <summary>How many rings are falling inward at once. 3.</summary>
public static int Collapse { get => _collapse ?? 3; set => _collapse = value; }
static float? _collapseSeconds;
/// <summary>How long one ring takes to fall from the rim to the core. 1.6s.</summary>
public static float CollapseSeconds
{
get => _collapseSeconds ?? 1.6f;
set => _collapseSeconds = value;
}
static float? _core;
/// <summary>Where the arms stop, as a fraction of the radius. 0.09.</summary>
///
/// ⚠️ NOT ZERO, AND NOT ONLY FOR LOOKS. Every arm converging on one point is a bright knot of
/// overlapping additive lines. Stopping them on a small circle leaves the middle unlit, which
/// is what makes it read as a hole rather than as a star.
public static float CoreFraction { get => _core ?? 0.09f; set => _core = value; }
static float? _width;
/// <summary>
/// Line thickness. 2.4.
///
/// ⚠️ `LineRenderer.Width` IS A `Curve` AND ITS UNITS ARE NOT WORLD UNITS — `LightningArc`
/// learned that at 1.4 drawing a ribbon a foot across. Tuned by eye, like every other caller.
/// </summary>
public static float Width { get => _width ?? 2.4f; set => _width = value; }
static int? _heightRings;
/// <summary>Radial samples in the floor-height field. 7.</summary>
public static int HeightRings { get => _heightRings ?? 7; set => _heightRings = value; }
static int? _heightSpokes;
/// <summary>Angular samples in the floor-height field. 24.</summary>
public static int HeightSpokes { get => _heightSpokes ?? 24; set => _heightSpokes = value; }
static float? _maxStep;
/// <summary>
/// A sample this far from the centre's height is a wall, not a floor. 48.
///
/// ⚠️ `PitVisual.MaxStep`'S REASONING, APPLIED TO A GRID. Without it the arms climb the side of
/// any crate they cross; flattening the sample to the centre's height puts that point inside
/// the geometry, where the wall occludes the line and nothing is drawn — which is correct.
/// </summary>
public static float MaxStep { get => _maxStep ?? 48f; set => _maxStep = value; }
static bool? _light;
/// <summary>A light in the middle. On.</summary>
public static bool Light { get => _light ?? true; set => _light = value; }
static float? _brightness;
/// <summary>How hard that light burns. 6.</summary>
public static float Brightness { get => _brightness ?? 6f; set => _brightness = value; }
/// <summary>The authored rim and core colours — a deep violet winding into near-white.</summary>
///
/// ⚠️ EXPRESSIONS, NOT `static readonly` FIELDS, so a hotload cannot freeze them at whatever
/// they held when the session started. INSTRUCTIONS.md §1 again.
public static Color DefaultRim => new Color( 0.42f, 0.16f, 0.88f );
public static Color DefaultCore => new Color( 0.93f, 0.78f, 1f );
static Color? _rimColour, _coreColour;
public static Color RimColour { get => _rimColour ?? DefaultRim; set => _rimColour = value; }
public static Color CoreColour { get => _coreColour ?? DefaultCore; set => _coreColour = value; }
/// <summary>Lift off the floor, so the lines do not z-fight with it.</summary>
const float Lift = 2f;
// ══ per-vortex state ═════════════════════════════════════════════════════
/// <summary>The reach this is drawing. Set by <see cref="Spawn"/>.</summary>
public float Radius { get; set; } = 900f;
/// <summary>How long it stays open.</summary>
public float Seconds { get; set; } = 6f;
/// <summary>Colour at the rim, and at the core.</summary>
public Color Rim { get; set; } = DefaultRim;
public Color Core { get; set; } = DefaultCore;
/// <summary>
/// Floor height sampled on a polar grid, ONCE, at spawn. `[ring, spoke]`.
/// </summary>
///
/// ⛔ THIS IS THE ONE PERFORMANCE DECISION IN THE FILE, AND IT IS FREE BECAUSE THE VORTEX DOES
/// NOT MOVE. `ShockRing` has to re-trace every point every frame — its ring changes radius, so
/// last frame's floor is not this frame's. `PitVisual`'s drag rings have the same problem and
/// pay for it by re-tracing at 20 Hz instead of 60. Nothing here changes shape in a way that
/// moves it over new ground: the disc is fixed and only the drawing inside it turns. So the
/// floor is measured once — 7 × 24 = 168 traces at spawn — and every point for the rest of the
/// effect is a bilinear lookup. **Zero traces per frame**, which is why this can afford five
/// arms at 40 points and rebuild all of them at frame rate rather than on a timer.
float[,] _height;
Vector3 _at;
float _born;
LineRenderer _rimLine;
LineRenderer _coreLine;
readonly List<LineRenderer> _armLines = new();
readonly List<LineRenderer> _ringLines = new();
/// <summary>
/// Scratch point buffers, one per line, refilled in place every frame.
/// </summary>
///
/// ⚠️ SO THE REBUILD ALLOCATES NOTHING. Ten lines rebuilt at 60 Hz for six seconds is 3,600
/// lists a second if each frame news its own, and a one-shot effect has no business handing
/// the collector that.
readonly Dictionary<LineRenderer, List<Vector3>> _buffers = new();
PointLight _pointLight;
// ══ spawning ═════════════════════════════════════════════════════════════
/// <summary>
/// Open a vortex on the floor at <paramref name="at"/>.
/// </summary>
///
/// ⚠️ ITS OWN GAMEOBJECT, AND IT DESTROYS ITSELF, exactly like `ShockRing`. Nothing has to
/// remember it, which is what lets a one-shot be fired from a static with no bookkeeping.
///
/// ⚠️ THE CENTRE IS SNAPPED TO THE FLOOR through `PitVisual.GroundAt`, which already traces
/// from above and ignores bodies — so a caster standing on his own origin does not put the
/// centre of the disc on his own head.
public static Vortex Spawn( Vector3 at, float radius, float seconds,
Color? rim = null, Color? core = null )
{
var scene = Game.ActiveScene;
if ( !scene.IsValid() ) return null;
var go = scene.CreateObject();
go.Name = "nz_vortex";
go.Flags |= GameObjectFlags.NotSaved;
go.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
go.WorldPosition = PitVisual.GroundAt( at );
var v = go.Components.Create<Vortex>();
v.Radius = MathF.Max( 32f, radius );
v.Seconds = MathF.Max( 0.2f, seconds );
v.Rim = rim ?? RimColour;
v.Core = core ?? CoreColour;
return v;
}
/// <summary>
/// Open one here AND on every other machine. For a vortex whose cause is host-only.
/// </summary>
///
/// ⛔ A BOSS IS SIMULATED ON THE HOST ALONE, so `Spawn` on its own puts a 900-unit attack
/// telegraph on exactly one screen. ALL VISUAL EFFECTS ARE GLOBAL. The same shape as
/// `ShockRing.FireShared` and `NZSound.PlayShared`: draw locally either way, announce only as
/// host, and the receiving side skips the host so it cannot double.
public static Vortex SpawnShared( Vector3 at, float radius, float seconds,
Color? rim = null, Color? core = null )
{
if ( Networking.IsActive && NZGame.IsHost )
{
var r = rim ?? RimColour;
var c = core ?? CoreColour;
// ⚠️ SIX FLOATS RATHER THAN TWO `Color`s, for the reason given on `NZNet.ShockRingFx`:
// an RPC argument that fails to serialise fails at RUNTIME, with the effect simply
// absent on the far side — indistinguishable from the bug this exists to fix.
NZNet.VortexFx( at, radius, seconds, r.r, r.g, r.b, c.r, c.g, c.b );
}
return Spawn( at, radius, seconds, rim, core );
}
// ══ building ═════════════════════════════════════════════════════════════
protected override void OnStart()
{
_born = Time.Now;
_at = WorldPosition;
BuildHeightField();
// ⚠️ THE RIM IS THE ONE LINE DRAWN IN THE RIM COLOUR FLAT. Everything else gradients toward
// the core; the boundary should read as one even edge, because its job is to be a boundary.
// ⚠️ THE RIM IS DRAWN THICKER THAN THE ARMS. It is the only line carrying information —
// where the pull stops — and at equal width the arms' outer ends run close enough to it
// that the boundary stops being obvious, which for a telegraph is the one failure that
// matters.
_rimLine = MakeLine( Flat( Rim ), Width * 1.3f );
_coreLine = MakeLine( Flat( Core ), Width * 1.35f );
for ( var i = 0; i < Math.Max( 1, Arms ); i++ )
_armLines.Add( MakeLine( RimToCore(), Width ) );
for ( var i = 0; i < Math.Max( 0, Collapse ); i++ )
_ringLines.Add( MakeLine( Flat( Rim ), Width * 0.8f ) );
if ( Light )
{
var lightGo = Scene.CreateObject();
lightGo.Name = "nz_vortex_light";
lightGo.Flags |= GameObjectFlags.NotSaved;
lightGo.NetworkMode = NetworkMode.Never; // ⛔ THIS MACHINE'S OWN — out of a joiner's snapshot, where it would stand frozen (NZNetListener)
lightGo.SetParent( GameObject );
lightGo.LocalPosition = Vector3.Up * 48f;
_pointLight = lightGo.Components.Create<PointLight>();
_pointLight.LightColor = Core * MathF.Max( 0f, Brightness );
_pointLight.Radius = Radius * 0.6f;
_pointLight.Shadows = false;
}
Draw( 0f );
}
static Gradient Flat( Color c ) => new Gradient( new Gradient.ColorFrame( 0f, c ) );
/// <summary>Dim at the start of the line, hot at the end — and the arms run rim → core.</summary>
Gradient RimToCore() => new Gradient(
new Gradient.ColorFrame( 0f, Rim ),
new Gradient.ColorFrame( 1f, Core ) );
LineRenderer MakeLine( Gradient colour, float width )
{
var line = Components.Create<LineRenderer>();
line.UseVectorPoints = true;
line.Additive = true;
line.Lighting = false;
line.CastShadows = false;
line.Color = colour;
line.Width = MathF.Max( 0.05f, width );
_buffers[line] = new List<Vector3>( Math.Max( ArmSegments, RimSegments ) + 2 );
return line;
}
/// <summary>
/// Measure the floor across the whole disc, once.
/// </summary>
///
/// ⚠️ THE OUTER SAMPLES SIT EXACTLY ON THE RADIUS, because `i / (rings-1)` reaches 1 — so the
/// rim ring is interpolating between real measurements rather than extrapolating past the last
/// one, which would leave it hovering wherever the ground fell away at the edge.
void BuildHeightField()
{
var rings = Math.Max( 2, HeightRings );
var spokes = Math.Max( 6, HeightSpokes );
var step = MathF.Max( 0f, MaxStep );
_height = new float[rings, spokes];
for ( var i = 0; i < rings; i++ )
{
var r = Radius * (i / (float)(rings - 1));
for ( var j = 0; j < spokes; j++ )
{
var a = j / (float)spokes * MathF.PI * 2f;
var p = _at + new Vector3( MathF.Cos( a ), MathF.Sin( a ), 0f ) * r;
var g = PitVisual.GroundAt( p );
_height[i, j] = MathF.Abs( g.z - _at.z ) > step ? _at.z : g.z;
}
}
}
/// <summary>Floor height anywhere on the disc, bilinear between the samples.</summary>
///
/// ⚠️ THE ANGULAR AXIS WRAPS. Spoke `n-1` interpolates back to spoke 0, not off the end of the
/// array — without that the disc has a seam at zero degrees where every line snaps to a
/// different height.
float GroundZ( float r, float ang )
{
if ( _height is null ) return _at.z;
var rings = _height.GetLength( 0 );
var spokes = _height.GetLength( 1 );
var fr = MathX.Clamp( r / MathF.Max( 1f, Radius ), 0f, 1f ) * (rings - 1);
var i0 = Math.Clamp( (int)MathF.Floor( fr ), 0, rings - 1 );
var i1 = Math.Min( i0 + 1, rings - 1 );
var ti = fr - i0;
var turn = ang / (MathF.PI * 2f);
turn -= MathF.Floor( turn );
var fa = turn * spokes;
var j0 = Math.Clamp( (int)MathF.Floor( fa ), 0, spokes - 1 ) % spokes;
var j1 = (j0 + 1) % spokes;
var tj = fa - MathF.Floor( fa );
var lo = MathX.Lerp( _height[i0, j0], _height[i0, j1], tj );
var hi = MathX.Lerp( _height[i1, j0], _height[i1, j1], tj );
return MathX.Lerp( lo, hi, ti );
}
Vector3 On( float r, float ang ) => new Vector3(
_at.x + MathF.Cos( ang ) * r,
_at.y + MathF.Sin( ang ) * r,
GroundZ( r, ang ) + Lift );
/// <summary>
/// Lay a closed circle into a line's buffer.
/// </summary>
///
/// ⚠️ CLOSED — the last point repeats the first — because `LineRenderer` draws a polyline and
/// not a loop. `ShockRing` and `PitVisual` both have the same note; without it every circle
/// here has a bite out of it at zero degrees.
void Circle( LineRenderer line, float r )
{
if ( !line.IsValid() || !_buffers.TryGetValue( line, out var buf ) ) return;
var n = Math.Max( 8, RimSegments );
buf.Clear();
for ( var i = 0; i <= n; i++ )
buf.Add( On( r, i / (float)n * MathF.PI * 2f ) );
line.VectorPoints = buf;
}
/// <summary>One arm, from the rim inward, at the given moment.</summary>
void Arm( LineRenderer line, int index, float age )
{
if ( !line.IsValid() || !_buffers.TryGetValue( line, out var buf ) ) return;
var n = Math.Max( 4, ArmSegments );
var inner = Radius * MathX.Clamp( CoreFraction, 0.02f, 0.9f );
var sweep = Turns * MathF.PI * 2f;
var tight = MathF.Max( 0.2f, Tightness );
var spun = age * Spin * MathF.PI * 2f;
var offset = index / (float)Math.Max( 1, Arms ) * MathF.PI * 2f;
buf.Clear();
for ( var i = 0; i <= n; i++ )
{
var t = i / (float)n;
// ⚠️ RADIUS FALLS AS A POWER OF (1-t), SO POINTS CROWD AT THE CORE. See `Tightness`.
var r = inner + (Radius - inner) * MathF.Pow( 1f - t, tight );
// ⛔ THE SHEAR TERM IS WHAT MAKES IT A VORTEX. `t * Shear` means the core end has turned
// further than the rim end by now, and the gap grows every frame — so the spiral winds
// tighter as you watch instead of holding one shape and rotating.
var ang = offset + t * sweep + spun * (1f + Shear * t);
buf.Add( On( r, ang ) );
}
line.VectorPoints = buf;
}
// ══ running ══════════════════════════════════════════════════════════════
protected override void OnUpdate()
{
var age = Time.Now - _born;
if ( age >= Seconds )
{
GameObject.Destroy();
return;
}
Draw( age );
}
void Draw( float age )
{
// ⚠️ IT FADES IN AS WELL AS OUT. A 900-unit disc appearing at full brightness on one frame
// reads as a bug; a third of a second of rise reads as something opening.
var fade = MathX.Clamp( age / 0.35f, 0f, 1f )
* MathX.Clamp( (Seconds - age) / 0.6f, 0f, 1f );
var inner = Radius * MathX.Clamp( CoreFraction, 0.02f, 0.9f );
Circle( _rimLine, Radius );
if ( _rimLine.IsValid() ) _rimLine.Color = Flat( Rim.WithAlpha( fade ) );
// ⚠️ THE CORE BREATHES, at a rate unrelated to the spin, so the middle never looks like a
// still image while the arms move around it.
var pulse = 0.85f + 0.15f * MathF.Sin( age * 9f );
Circle( _coreLine, inner * pulse );
if ( _coreLine.IsValid() ) _coreLine.Color = Flat( Core.WithAlpha( fade ) );
for ( var i = 0; i < _armLines.Count; i++ )
{
Arm( _armLines[i], i, age );
if ( _armLines[i].IsValid() )
_armLines[i].Color = new Gradient(
new Gradient.ColorFrame( 0f, Rim.WithAlpha( fade * 0.55f ) ),
new Gradient.ColorFrame( 1f, Core.WithAlpha( fade ) ) );
}
// ⚠️ STAGGERED BY INDEX so they are evenly spaced down the fall rather than leaving the rim
// together — three rings arriving as one is one ring that happens to be brighter.
var period = MathF.Max( 0.2f, CollapseSeconds );
for ( var i = 0; i < _ringLines.Count; i++ )
{
var p = (age / period + i / (float)_ringLines.Count) % 1f;
var r = MathX.Lerp( Radius, inner, p );
Circle( _ringLines[i], r );
// ⚠️ IN QUICKLY, OUT ALL THE WAY TO ZERO. The phase wraps at 1, so a ring still visible
// there would pop back to the rim in one frame.
var a = MathF.Min( p / 0.15f, 1f ) * (1f - p);
if ( _ringLines[i].IsValid() )
_ringLines[i].Color = Flat( Color.Lerp( Rim, Core, p ).WithAlpha( fade * a ) );
}
if ( _pointLight.IsValid() )
_pointLight.LightColor = Core * (MathF.Max( 0f, Brightness ) * fade * pulse);
}
protected override void OnDestroy()
{
// ⚠️ THE LIGHT IS A CHILD OBJECT, not a component on this one, so destroying this component
// does not take it with it.
if ( _pointLight.IsValid() && _pointLight.GameObject.IsValid() )
_pointLight.GameObject.Destroy();
}
// ══ diagnostics ══════════════════════════════════════════════════════════
/// <summary>`nz_vortex` — the resolved look and how many are open.</summary>
[ConCmd( "nz_vortex" )]
public static void Report()
{
var live = Game.ActiveScene?.GetAllComponents<Vortex>().Count() ?? 0;
Log.Info( $"[nz-fx] VORTEX · {live} open · {Arms} arm(s) × {ArmSegments}pt"
+ $" · {Turns:0.##} turn(s) · tightness {Tightness:0.##}" );
Log.Info( $"[nz-fx] spin {Spin:0.##}/s · shear {Shear:0.##}"
+ $" · {Collapse} collapsing ring(s) over {CollapseSeconds:0.##}s"
+ $" · core {CoreFraction:0.###}" );
Log.Info( $"[nz-fx] rim {RimColour.r:0.##},{RimColour.g:0.##},{RimColour.b:0.##}"
+ $" → core {CoreColour.r:0.##},{CoreColour.g:0.##},{CoreColour.b:0.##}"
+ $" · light {(Light ? $"on ×{Brightness:0.#}" : "off")}" );
Log.Info( $"[nz-fx] floor field {HeightRings}×{HeightSpokes}"
+ $" = {Math.Max( 2, HeightRings ) * Math.Max( 6, HeightSpokes )} trace(s) at spawn,"
+ " 0 per frame" );
}
/// <summary>
/// `nz_vortex_test [radius] [seconds]` — open one at your feet.
/// </summary>
///
/// ⚠️ IT EXISTS BECAUSE THE REAL TRIGGER IS A BOSS ROLLING ONE ATTACK IN THREE. Waiting out
/// `nz_oberon_move hole` and its wind-up is not a way to tune a spiral.
[ConCmd( "nz_vortex_test" )]
public static void TestCmd( float radius = 900f, float seconds = 6f )
{
var p = NZPlayer.Local;
if ( !p.IsValid() ) { Log.Warning( "[nz-fx] no player" ); return; }
// ⚠️ `Spawn`, NOT `SpawnShared`. A preview is for whoever typed the command.
Spawn( p.WorldPosition, radius, seconds );
Log.Info( $"[nz-fx] test vortex {radius:0}u for {seconds:0.##}s" );
}
/// <summary>`nz_vortex_set <key> <value>` — retune it, then preview.</summary>
[ConCmd( "nz_vortex_set" )]
public static void SetCmd( string key = "", float value = 0f )
{
switch ( key.ToLowerInvariant() )
{
case "arms": Arms = (int)value; break;
case "turns": Turns = value; break;
case "tightness": Tightness = value; break;
case "spin": Spin = value; break;
case "shear": Shear = value; break;
case "armsegments": ArmSegments = (int)value; break;
case "rimsegments": RimSegments = (int)value; break;
case "collapse": Collapse = (int)value; break;
case "collapseseconds": CollapseSeconds = value; break;
case "core": CoreFraction = value; break;
case "width": Width = value; break;
case "heightrings": HeightRings = (int)value; break;
case "heightspokes": HeightSpokes = (int)value; break;
case "maxstep": MaxStep = value; break;
case "light": Light = value > 0.5f; break;
case "brightness": Brightness = value; break;
default:
Log.Info( "[nz-fx] nz_vortex_set <arms|turns|tightness|spin|shear|armsegments"
+ "|rimsegments|collapse|collapseseconds|core|width|heightrings|heightspokes"
+ "|maxstep|light|brightness> <value>" );
return;
}
Log.Info( $"[nz-fx] vortex {key} = {value:0.###}" );
Report();
}
/// <summary>
/// `nz_vortex_colour <r> <g> <b> [cr] [cg] [cb]` — rim colour, then core.
/// </summary>
///
/// ⚠️ WITH THREE ARGUMENTS IT SETS THE RIM AND LEAVES THE CORE, because the rim is the one
/// people mean by "what colour is it". Six sets both. Values are 0–1.
[ConCmd( "nz_vortex_colour" )]
public static void ColourCmd( float r = -1f, float g = 0f, float b = 0f,
float cr = -1f, float cg = 0f, float cb = 0f )
{
if ( r >= 0f ) RimColour = new Color( r, g, b );
if ( cr >= 0f ) CoreColour = new Color( cr, cg, cb );
Report();
var p = NZPlayer.Local;
if ( p.IsValid() ) Spawn( p.WorldPosition, 900f, 4f );
}
}