using System;
namespace SceneTests.Components;
///
/// Pins the emitter geometry that ParticleTests.cs leaves untouched: the cone
/// emitter's spawn envelope and velocity direction, the ring emitter's radius,
/// arc, flatness and velocity-from-center behavior, plus the pure helpers on
/// Particle itself (Create defaults, the data store, ApplyDamping and Rand).
///
[TestClass]
public class ParticleEmitterGeometryTest
{
///
/// Creates a GameObject at the given position holding a ParticleEffect with a
/// long lifetime, so burst particles survive the assertion tick.
///
static ParticleEffect CreateEffect( Scene scene, Vector3 position, out GameObject go )
{
go = scene.CreateObject();
go.WorldPosition = position;
var effect = go.Components.Create();
effect.Lifetime = 10.0f;
return effect;
}
///
/// A cone emitter in default placement mode (neither OnEdge nor InVolume) spawns
/// every particle on the near-plane disc: zero forward offset and a radial
/// distance of at most tan(angle) * ConeNear.
///
[TestMethod]
public void ConeEmitter_DefaultSpawnsOnNearDisc()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, new Vector3( 100, 200, 300 ), out var go );
var emitter = go.Components.Create();
emitter.Burst = 64.0f;
emitter.Rate = 0.0f;
emitter.ConeAngle = 30.0f;
emitter.ConeNear = 10.0f;
emitter.ConeFar = 50.0f;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 64, effect.Particles.Count );
var maxRadius = MathF.Tan( MathX.DegreeToRadian( 30.0f ) ) * 10.0f;
foreach ( var p in effect.Particles )
{
var local = p.StartPosition - go.WorldPosition;
var radial = MathF.Sqrt( local.y * local.y + local.z * local.z );
Assert.IsTrue( MathF.Abs( local.x ) <= 0.01f, $"default placement spawns on the near plane, got x {local.x}" );
Assert.IsTrue( radial <= maxRadius + 0.1f, $"radial {radial} exceeds the near disc radius {maxRadius}" );
}
}
///
/// With InVolume enabled the spawn positions fill the truncated cone between
/// ConeNear and ConeFar: the forward offset spans [0, far - near] and the radial
/// distance never exceeds tan(angle) * (offset + near).
///
[TestMethod]
public void ConeEmitter_InVolumeStaysInsideConeEnvelope()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, new Vector3( -50, 0, 25 ), out var go );
var emitter = go.Components.Create();
emitter.Burst = 96.0f;
emitter.Rate = 0.0f;
emitter.ConeAngle = 30.0f;
emitter.ConeNear = 10.0f;
emitter.ConeFar = 50.0f;
emitter.InVolume = true;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 96, effect.Particles.Count );
var tan = MathF.Tan( MathX.DegreeToRadian( 30.0f ) );
foreach ( var p in effect.Particles )
{
var local = p.StartPosition - go.WorldPosition;
var radial = MathF.Sqrt( local.y * local.y + local.z * local.z );
Assert.IsTrue( local.x >= -0.01f && local.x <= 40.01f, $"forward offset {local.x} outside [0, 40]" );
Assert.IsTrue( radial <= tan * (local.x + 10.0f) + 0.25f,
$"radial {radial} at forward offset {local.x} pokes out of the 30 degree cone" );
}
}
///
/// With OnEdge enabled every particle sits exactly on the cone surface: the
/// radial distance equals tan(angle) * (offset + near) instead of filling the
/// interior.
///
[TestMethod]
public void ConeEmitter_OnEdgeSpawnsOnConeSurface()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, Vector3.Zero, out var go );
var emitter = go.Components.Create();
emitter.Burst = 64.0f;
emitter.Rate = 0.0f;
emitter.ConeAngle = 30.0f;
emitter.ConeNear = 10.0f;
emitter.ConeFar = 50.0f;
emitter.OnEdge = true;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 64, effect.Particles.Count );
var tan = MathF.Tan( MathX.DegreeToRadian( 30.0f ) );
foreach ( var p in effect.Particles )
{
var local = p.StartPosition - go.WorldPosition;
var radial = MathF.Sqrt( local.y * local.y + local.z * local.z );
var expected = tan * (local.x + 10.0f);
Assert.AreEqual( expected, radial, 0.25f, $"OnEdge particle at offset {local.x} should sit on the cone surface" );
}
}
///
/// The cone emitter redirects the effect's start velocity along the cone
/// (from the cone tip through the spawn point) and scales it by
/// VelocityMultiplier - so every velocity stays within the cone angle of the
/// emitter's forward axis with the multiplied speed.
///
[TestMethod]
public void ConeEmitter_VelocityFollowsConeDirection()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, Vector3.Zero, out var go );
effect.StartVelocity = 100.0f;
var emitter = go.Components.Create();
emitter.Burst = 64.0f;
emitter.Rate = 0.0f;
emitter.ConeAngle = 30.0f;
emitter.ConeNear = 10.0f;
emitter.ConeFar = 50.0f;
emitter.InVolume = true;
emitter.VelocityMultiplier = 2.0f;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 64, effect.Particles.Count );
var minDot = MathF.Cos( MathX.DegreeToRadian( 31.0f ) );
foreach ( var p in effect.Particles )
{
Assert.AreEqual( 200.0f, p.Velocity.Length, 2.0f, "the 100 start velocity should be doubled by the multiplier" );
var dot = Vector3.Dot( p.Velocity.Normal, Vector3.Forward );
Assert.IsTrue( dot >= minDot, $"velocity {p.Velocity} leaves the 30 degree cone (dot {dot})" );
}
}
///
/// A ring emitter with zero thickness spawns every particle exactly on the ring:
/// at the configured radius in the emitter's xy plane.
///
[TestMethod]
public void RingEmitter_SpawnsOnRadiusInPlane()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, new Vector3( 10, 20, 30 ), out var go );
var emitter = go.Components.Create();
emitter.Burst = 64.0f;
emitter.Rate = 0.0f;
emitter.Radius = 50.0f;
emitter.Thickness = 0.0f;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 64, effect.Particles.Count );
foreach ( var p in effect.Particles )
{
var local = p.StartPosition - go.WorldPosition;
var planar = MathF.Sqrt( local.x * local.x + local.y * local.y );
Assert.AreEqual( 50.0f, planar, 0.1f, "zero thickness particles sit exactly on the ring" );
Assert.IsTrue( MathF.Abs( local.z ) <= 0.01f, $"the ring is flat in z, got {local.z}" );
}
}
///
/// AngleStart/Angle restrict spawning to an arc: a 90 degree arc starting at 0
/// uses angles whose sin and cos are both non-negative, so every spawn offset
/// has non-negative x and y.
///
[TestMethod]
public void RingEmitter_ArcRestrictsSpawnAngles()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, Vector3.Zero, out var go );
var emitter = go.Components.Create();
emitter.Burst = 64.0f;
emitter.Rate = 0.0f;
emitter.Radius = 50.0f;
emitter.Thickness = 0.0f;
emitter.AngleStart = 0.0f;
emitter.Angle = 90.0f;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 64, effect.Particles.Count );
foreach ( var p in effect.Particles )
{
var local = p.StartPosition - go.WorldPosition;
Assert.IsTrue( local.x >= -0.01f, $"a 0-90 arc never spawns at negative x, got {local.x}" );
Assert.IsTrue( local.y >= -0.01f, $"a 0-90 arc never spawns at negative y, got {local.y}" );
}
}
///
/// VelocityFromCenter pushes each particle radially away from the ring center
/// with exactly that speed when the effect itself adds no start velocity.
///
[TestMethod]
public void RingEmitter_VelocityFromCenterPushesOutward()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, Vector3.Zero, out var go );
var emitter = go.Components.Create();
emitter.Burst = 32.0f;
emitter.Rate = 0.0f;
emitter.Radius = 50.0f;
emitter.Thickness = 0.0f;
emitter.VelocityFromCenter = 25.0f;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 32, effect.Particles.Count );
foreach ( var p in effect.Particles )
{
var outward = (p.StartPosition - go.WorldPosition).Normal;
Assert.AreEqual( 25.0f, p.Velocity.Length, 0.5f, "the velocity magnitude comes straight from VelocityFromCenter" );
Assert.IsTrue( Vector3.Dot( p.Velocity.Normal, outward ) >= 0.999f,
$"velocity {p.Velocity} should point radially outward from the center" );
}
}
///
/// Thickness scatters particles around the ring (up to the thickness in any
/// direction), and Flatness 1 removes the z component of that scatter entirely.
///
[TestMethod]
public void RingEmitter_FlatnessRemovesVerticalScatter()
{
var scene = new Scene();
using var sceneScope = scene.Push();
var effect = CreateEffect( scene, Vector3.Zero, out var go );
var emitter = go.Components.Create();
emitter.Burst = 64.0f;
emitter.Rate = 0.0f;
emitter.Radius = 50.0f;
emitter.Thickness = 20.0f;
emitter.Flatness = 1.0f;
emitter.ResetEmitter();
scene.GameTick();
Assert.AreEqual( 64, effect.Particles.Count );
foreach ( var p in effect.Particles )
{
var local = p.StartPosition - go.WorldPosition;
var planar = MathF.Sqrt( local.x * local.x + local.y * local.y );
Assert.IsTrue( MathF.Abs( local.z ) <= 0.01f, $"full flatness removes all z scatter, got {local.z}" );
Assert.IsTrue( planar >= 29.0f && planar <= 71.0f,
$"thickness 20 keeps particles within 20 units of the 50 ring, got {planar}" );
}
}
///
/// Particle.Create resets the pooled instance to its documented defaults: white
/// color, full alpha, size 5, zero velocity/age/angles, frame and sequence zero
/// and a unit time scale.
///
[TestMethod]
public void Particle_CreateDefaults()
{
var p = Particle.Create();
Assert.AreEqual( 0f, p.Age );
Assert.AreEqual( 0, p.Frame );
Assert.AreEqual( Angles.Zero, p.Angles );
Assert.AreEqual( Vector3.Zero, p.Velocity );
Assert.AreEqual( Color.White, p.Color );
Assert.AreEqual( Color.White.WithAlpha( 0 ), p.OverlayColor );
Assert.AreEqual( 1f, p.Alpha );
Assert.AreEqual( 0, p.Sequence );
Assert.AreEqual( Vector3.Zero, p.SequenceTime );
Assert.AreEqual( new Vector3( 5, 5, 5 ), p.Size );
Assert.AreEqual( 1f, p.TimeScale );
Assert.AreEqual( -1000f, p.HitTime );
Assert.AreEqual( -1000f, p.LastHitTime );
}
///
/// The per-particle data store: Set/Get round trips typed values, keys are
/// case-insensitive, missing keys return the type default and setting an
/// existing key overwrites it.
///
[TestMethod]
public void Particle_DataStore()
{
var p = Particle.Create();
Assert.AreEqual( 0, p.Get( "missing" ), "missing keys return default" );
Assert.IsNull( p.Get( "missing" ) );
p.Set( "speed", 42 );
Assert.AreEqual( 42, p.Get( "speed" ) );
Assert.AreEqual( 42, p.Get( "SPEED" ), "keys are case-insensitive" );
p.Set( "Speed", 7 );
Assert.AreEqual( 7, p.Get( "speed" ), "setting an existing key overwrites it" );
p.Set( "target", new Vector3( 1, 2, 3 ) );
Assert.AreEqual( new Vector3( 1, 2, 3 ), p.Get( "target" ) );
}
///
/// ApplyDamping uses source-style friction with a stop speed of 100: fast
/// velocities bleed proportionally, slow velocities bleed against the stop speed
/// (so they hit zero), zero damping changes nothing and direction is preserved.
///
[TestMethod]
public void Particle_ApplyDamping()
{
var p = Particle.Create();
p.Velocity = new Vector3( 1000, 0, 0 );
p.ApplyDamping( 0.5f );
Assert.AreEqual( 500f, p.Velocity.x, 0.01f, "above the stop speed the drop is speed * amount" );
p.Velocity = new Vector3( 1000, 0, 0 );
p.ApplyDamping( 0f );
Assert.AreEqual( 1000f, p.Velocity.x, 0.01f, "zero damping leaves the velocity alone" );
p.Velocity = new Vector3( 50, 0, 0 );
p.ApplyDamping( 0.5f );
Assert.AreEqual( Vector3.Zero, p.Velocity, "below the 100 stop speed the drop is 100 * amount, stopping it dead" );
p.Velocity = new Vector3( 0, 300, 400 );
p.ApplyDamping( 0.2f );
Assert.AreEqual( 400f, p.Velocity.Length, 0.1f, "speed 500 drops by 500 * 0.2" );
Assert.AreEqual( 240f, p.Velocity.y, 0.1f, "damping preserves the direction" );
Assert.AreEqual( 320f, p.Velocity.z, 0.1f );
}
///
/// Particle.Rand is deterministic per particle: the same seed and line produce
/// the same value in [0, 1), so controllers can derive stable per-particle
/// randomness from it.
///
[TestMethod]
public void Particle_RandIsDeterministic()
{
var p = Particle.Create();
var a = p.Rand( 3, 77 );
var b = p.Rand( 3, 77 );
Assert.AreEqual( a, b, "the same seed and line always produce the same value" );
Assert.IsTrue( a >= 0f && a < 1f, $"Rand should stay in [0, 1), got {a}" );
var c = p.Rand( 4, 77 );
Assert.IsTrue( c >= 0f && c < 1f );
}
}