Background particle system code for a level. BackgroundParticleSettings resolves and clamps authored per-level emitter values, and BackgroundParticle is an Entity2D that simulates and renders an emitted square particle (main particle or short-lived burst fragment), including movement, collision with solids and arena walls, optional impact spray spawning, and optional trail rendering.
namespace BlockParty;
/// <summary>
/// Resolved runtime settings for a level's background particle emitter (see
/// <see cref="LevelDef.BackgroundParticlesEnabled"/>): the per-level authored values with defaults
/// applied and everything clamped to sane ranges. Built by the stage's
/// <c>BuildBackgroundParticleSettings</c> hook (null = the feature is off) and cached until the
/// editor invalidates it, so the emitter never re-reads the level mid-tick.
/// </summary>
public sealed class BackgroundParticleSettings
{
/// <summary>Default square colour when the level doesn't set one — a soft rain-like blue-grey.</summary>
public static readonly Color DefaultColor = new( 188f / 255f, 216f / 255f, 226f / 255f );
public Direction Edge = Direction.Up;
/// <summary>Resolved square tint; the level's opacity rides in the alpha.</summary>
public Color Color = DefaultColor;
public int SizeMin = 1;
public int SizeMax = 2;
/// <summary>Degrees the emit direction tilts from straight-inward (positive = counter-clockwise).</summary>
public float Angle;
/// <summary>Random tilt added per particle, in degrees either side of <see cref="Angle"/>.</summary>
public float AngleRange;
public float SpeedMin = 90f;
public float SpeedMax = 140f;
/// <summary>World-vertical acceleration in px/s², positive = downward (negative keeps bottom-edge
/// embers rising).</summary>
public float Gravity = 60f;
/// <summary>Particles spawned per second.</summary>
public float SpawnRate = 12f;
/// <summary>Extra sprites rendered in the grid cells the square most recently vacated, forming a
/// streak behind it (rain lines). 0 = no trail.</summary>
public int TrailLength;
/// <summary>Resolved trail tint (falls back to the square colour, like the impact colour).</summary>
public Color TrailColor = DefaultColor;
/// <summary>True = segments fade toward the tail (comet look); false = the whole streak renders
/// at the particle's own opacity (solid line).</summary>
public bool TrailFade = true;
/// <summary>Whether destroyed particles burst into impact-spray fragments at all.</summary>
public bool ImpactEnabled;
/// <summary>Fragments per destroyed particle, rolled in [Min, Max] per impact (a 0 roll = none).</summary>
public int ImpactCountMin = 2;
public int ImpactCountMax = 4;
/// <summary>Fragment starting speed range in px/s.</summary>
public float ImpactSpeedMin = 15f;
public float ImpactSpeedMax = 40f;
/// <summary>Fragment square side length range in logical pixels.</summary>
public int ImpactSizeMin = 1;
public int ImpactSizeMax = 2;
public float ImpactGravity = 150f;
/// <summary>Random spray tilt, in degrees either side of the struck surface's outward normal.</summary>
public float ImpactAngleRange = 60f;
/// <summary>Resolved spray tint (falls back to the square colour).</summary>
public Color ImpactColor = DefaultColor;
public static BackgroundParticleSettings FromLevel( LevelDef d )
{
if ( d is null || !d.BackgroundParticlesEnabled ) return null;
return Build( d.BackgroundParticleEdge, d.BackgroundParticleColor, d.BackgroundParticleOpacity,
d.BackgroundParticleSizeMin, d.BackgroundParticleSizeMax,
d.BackgroundParticleAngle, d.BackgroundParticleAngleRange,
d.BackgroundParticleSpeedMin, d.BackgroundParticleSpeedMax,
d.BackgroundParticleGravity, d.BackgroundParticleSpawnRate,
d.BackgroundParticleTrailLength, d.BackgroundParticleTrailColor, d.BackgroundParticleTrailFade,
d.BackgroundParticleImpactEnabled, d.BackgroundParticleImpactCountMin, d.BackgroundParticleImpactCountMax,
d.BackgroundParticleImpactSpeedMin, d.BackgroundParticleImpactSpeedMax,
d.BackgroundParticleImpactSizeMin, d.BackgroundParticleImpactSizeMax,
d.BackgroundParticleImpactGravity, d.BackgroundParticleImpactAngleRange,
d.BackgroundParticleImpactColor );
}
public static BackgroundParticleSettings FromEditorLevel( EditorLevel e )
{
if ( e is null || !e.BackgroundParticlesEnabled ) return null;
return Build( e.BackgroundParticleEdge, e.BackgroundParticleColor, e.BackgroundParticleOpacity,
e.BackgroundParticleSizeMin, e.BackgroundParticleSizeMax,
e.BackgroundParticleAngle, e.BackgroundParticleAngleRange,
e.BackgroundParticleSpeedMin, e.BackgroundParticleSpeedMax,
e.BackgroundParticleGravity, e.BackgroundParticleSpawnRate,
e.BackgroundParticleTrailLength, e.BackgroundParticleTrailColor, e.BackgroundParticleTrailFade,
e.BackgroundParticleImpactEnabled, e.BackgroundParticleImpactCountMin, e.BackgroundParticleImpactCountMax,
e.BackgroundParticleImpactSpeedMin, e.BackgroundParticleImpactSpeedMax,
e.BackgroundParticleImpactSizeMin, e.BackgroundParticleImpactSizeMax,
e.BackgroundParticleImpactGravity, e.BackgroundParticleImpactAngleRange,
e.BackgroundParticleImpactColor );
}
// Defensive clamps (mirrors EditorLevel.FromJson) so a hand-edited level file can't produce a
// runaway emitter (e.g. a thousand-per-second spawn rate or an outward emit angle).
private static BackgroundParticleSettings Build( Direction edge, Color? color, float opacity,
int sizeMin, int sizeMax, float angle, float angleRange, float speedMin, float speedMax,
float gravity, float rate, int trailLength, Color? trailColor, bool trailFade,
bool impactEnabled, int impactCountMin, int impactCountMax,
float impactSpeedMin, float impactSpeedMax, int impactSizeMin, int impactSizeMax,
float impactGravity, float impactAngleRange, Color? impactColor )
{
var s = new BackgroundParticleSettings
{
Edge = edge is Direction.Down or Direction.Left or Direction.Right ? edge : Direction.Up,
SizeMin = Math.Clamp( sizeMin, 1, 8 ),
Angle = Math.Clamp( angle, -75f, 75f ),
AngleRange = Math.Clamp( angleRange, 0f, 90f ),
SpeedMin = Math.Clamp( speedMin, 5f, 300f ),
Gravity = Math.Clamp( gravity, -400f, 400f ),
SpawnRate = Math.Clamp( rate, 0f, 60f ),
TrailLength = Math.Clamp( trailLength, 0, 8 ),
TrailFade = trailFade,
ImpactEnabled = impactEnabled,
ImpactCountMin = Math.Clamp( impactCountMin, 0, 10 ),
ImpactSpeedMin = Math.Clamp( impactSpeedMin, 5f, 150f ),
ImpactSizeMin = Math.Clamp( impactSizeMin, 1, 8 ),
ImpactGravity = Math.Clamp( impactGravity, -400f, 400f ),
ImpactAngleRange = Math.Clamp( impactAngleRange, 0f, 90f ),
};
s.SizeMax = Math.Clamp( sizeMax, s.SizeMin, 8 );
s.SpeedMax = Math.Clamp( speedMax, s.SpeedMin, 300f );
s.ImpactCountMax = Math.Clamp( impactCountMax, s.ImpactCountMin, 10 );
s.ImpactSpeedMax = Math.Clamp( impactSpeedMax, s.ImpactSpeedMin, 150f );
s.ImpactSizeMax = Math.Clamp( impactSizeMax, s.ImpactSizeMin, 8 );
float alpha = Math.Clamp( opacity, 0.05f, 1f );
s.Color = (color ?? DefaultColor).WithAlpha( alpha );
s.TrailColor = (trailColor ?? color ?? DefaultColor).WithAlpha( alpha );
s.ImpactColor = (impactColor ?? color ?? DefaultColor).WithAlpha( alpha );
return s;
}
}
/// <summary>
/// One square of a level's authored background ambience (rain / snow / embers — see
/// <see cref="LevelDef.BackgroundParticlesEnabled"/>). Emitted from an arena edge by the stage
/// (<see cref="StageBase.TickBackgroundParticles"/>), it flies under constant world-vertical
/// gravity and is DESTROYED by the first solid it meets — interior obstacles, glass and live
/// blocks (never fences), or any arena wall (its emit edge only counts once it has first cleared
/// it) — optionally bursting into short-lived spray fragments along the struck surface's normal. Purely
/// decorative: it renders between the drifting backdrop and the fences, never touches gameplay
/// entities, and draws all randomness from the BACKGROUND Rng stream (like
/// <see cref="BackgroundBlock"/>), so level ambience can't perturb the sim or the
/// player-triggered cosmetic stream.
/// </summary>
public sealed class BackgroundParticle : Entity2D
{
/// <summary>Safety net for a near-stationary configuration (tiny speed, zero gravity): nothing
/// should live this long — a particle normally dies crossing the arena or striking a solid.</summary>
private const float MAX_LIFETIME = 30f;
/// <summary>Longest distance a main square may move per collision sample. The overlap test only
/// sees positions the square actually visits, so a fast particle (gravity accumulates well past
/// its starting speed) covering several px per tick would otherwise TUNNEL straight through a
/// thin obstacle or glass pane. 2px guarantees a sample inside anything at least that thin.</summary>
private const float MAX_MOVE_STEP = 2f;
public StageBase Stage { get; set; }
/// <summary>True for an impact-spray fragment (no collision, short fading lifetime). The stage's
/// spawn cap counts only the emitted squares, not these.</summary>
public bool IsBurst => _isBurst;
private Color _color;
private float _gravity; // px/s², positive = downward (applied as -Y; the play plane is +Y up)
private Direction _spawnEdge; // main squares only: graced until first cleared, then a wall like any other
private bool _leftSpawnEdge; // set once the square has fully cleared its emit edge (see CheckArenaWalls)
private bool _spraysOnImpact;
private bool _isBurst; // spray fragment: no collision, fades out over its short lifetime
private float _totalLifetime = MAX_LIFETIME;
private float _lifetime = MAX_LIFETIME;
private SpriteRenderer _sprite;
// Trail (main squares only): extra sprites rendered in the grid cells the square most recently
// vacated — a rain-streak line. _trailCells is a ring buffer of vacated cell centres, newest at
// _trailHead; sprites are children of this GameObject positioned by LOCAL offset from the
// current cell each SyncTransform.
private int _trailLength;
private Color _trailColor;
private bool _trailFade;
private SpriteRenderer[] _trailSprites;
private Vector2[] _trailCells;
private int _trailHead = -1;
private int _trailCount;
private Vector2 _lastCell;
private bool _hasCell;
/// <summary>Configure a main emitted square.</summary>
public void Setup( Vector2 velocity, float gravity, Color color, Direction spawnEdge, bool spraysOnImpact )
{
Velocity = velocity;
_gravity = gravity;
_color = color;
_spawnEdge = spawnEdge;
_spraysOnImpact = spraysOnImpact;
}
/// <summary>Enable the streak behind a main square. <paramref name="fade"/> thins the segments
/// toward the tail; false keeps the whole line at the trail colour's own opacity. Call before
/// <see cref="CreateVisuals"/> (which builds the segment sprites).</summary>
public void SetupTrail( int length, Color color, bool fade )
{
_trailLength = length;
_trailColor = color;
_trailFade = fade;
}
/// <summary>Configure an impact-spray fragment: collisionless, fading out over <paramref name="lifetime"/>.</summary>
public void SetupBurst( Vector2 velocity, float gravity, Color color, float lifetime )
{
Velocity = velocity;
_gravity = gravity;
_color = color;
_isBurst = true;
_totalLifetime = lifetime;
_lifetime = lifetime;
}
public void CreateVisuals( int childOrder )
{
_sprite = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Size, "idle", childOrder );
_sprite.Color = _color;
// The level's particle opacity rides in the tint's alpha (and bursts fade theirs out), so
// these must alpha-blend: opt out of the opaque default and zero the cutoff (which DISCARDS
// pixels under 0.5) — the same pairing as BackgroundBlock / lane overlays / field motes.
_sprite.Opaque = false;
_sprite.AlphaCutoff = 0f;
if ( _trailLength <= 0 ) return;
_trailSprites = new SpriteRenderer[_trailLength];
_trailCells = new Vector2[_trailLength];
for ( int i = 0; i < _trailLength; i++ )
{
// Sprite i is the segment nearest the head; deeper segments stack under it, and the
// caller passes the head a childOrder above _trailLength so it always tops its own trail.
var segment = SpriteLayer.Add( GameObject, "sprites/pixel.sprite", Size, "idle", _trailLength - 1 - i );
segment.Color = Color.Transparent; // hidden until its ring-buffer slot has data
segment.Opaque = false;
segment.AlphaCutoff = 0f;
_trailSprites[i] = segment;
}
}
public override void Tick( float dt )
{
if ( _isBurst )
{
Pos += Velocity * dt;
Velocity += new Vector2( 0f, -_gravity * dt );
// Fragments have no wall collision (a splash may straddle the wall band on purpose),
// so reclaim any that leave the arena square entirely rather than letting them fade
// out invisibly behind the edge blockers.
if ( ReclaimIfOutsideArena() )
return;
_lifetime -= dt;
if ( _sprite is not null )
_sprite.Color = _color.WithAlpha( _color.a * Math.Clamp( _lifetime / _totalLifetime, 0f, 1f ) );
if ( _lifetime <= 0f )
Dead = true;
return;
}
// Substep the move so the collision tests sample every MAX_MOVE_STEP px of travel — a
// single move-then-test would let a fast square skip clean through a thin solid.
Vector2 move = Velocity * dt;
int substeps = Math.Max( 1, (int)MathF.Ceiling( move.Length / MAX_MOVE_STEP ) );
Vector2 step = move / substeps;
for ( int i = 0; i < substeps; i++ )
{
Pos += step;
if ( CheckSolids() )
return;
if ( CheckArenaWalls() )
return;
// Belt-and-braces: the wall checks make escape impossible by construction, but if a
// square somehow ends up fully outside the arena anyway, reclaim it silently.
if ( ReclaimIfOutsideArena() )
return;
}
Velocity += new Vector2( 0f, -_gravity * dt );
_lifetime -= dt;
if ( _lifetime <= 0f )
Dead = true;
}
/// <summary>Die (with the optional spray) on the first overlapped solid. The stage rebuilt the
/// shared snapshot at the top of the tick: interior obstacles + glass + live blocks, never
/// fences (the same solid set Particle's bounce path uses).</summary>
private bool CheckSolids()
{
foreach ( RectF rect in Stage.BackgroundParticleSolids )
{
if ( !GetRect().Intersects( rect ) ) continue;
// Min-penetration eject snaps us flush against the struck face; the centre comparison
// turns the resolved axis into a signed outward normal (the BlockBulletProjectile idiom).
switch ( ResolveMinPenetration( rect ) )
{
case ReflectAxis.Horizontal:
Impact( new Vector2( X < (rect.Left + rect.Right) * 0.5f ? -1f : 1f, 0f ) );
return true;
case ReflectAxis.Vertical:
Impact( new Vector2( 0f, Y < (rect.Bottom + rect.Top) * 0.5f ? -1f : 1f ) );
return true;
}
}
return false;
}
/// <summary>Kill (silently) anything fully outside the 240x240 arena square. The area just past
/// the walls is masked by the edge blockers, so nothing out there is ever visible anyway.</summary>
private bool ReclaimIfOutsideArena()
{
float halfW = Width * 0.5f;
float halfH = Height * 0.5f;
if ( X + halfW < 0f || X - halfW > Arena.WIDTH || Y + halfH < 0f || Y - halfH > Arena.HEIGHT )
Dead = true;
return Dead;
}
/// <summary>Arena walls destroy the particle like any solid. The spawn edge gets a ONE-TIME
/// grace: no burst while the square has never yet cleared it (it was emitted flush with it) —
/// but once it's genuinely in flight, returning to that edge bursts like any other wall, so a
/// bottom-edge ember pulled back down by gravity splashes on the floor it rose from. The
/// non-spawn walls are checked FIRST so a particle grazing a corner during the grace bursts on
/// the perpendicular wall rather than slipping past it.</summary>
private bool CheckArenaWalls()
{
float halfW = Width * 0.5f;
float halfH = Height * 0.5f;
if ( (_leftSpawnEdge || _spawnEdge != Direction.Left) && X - halfW < Arena.WALL_SIZE )
return WallImpact( new Vector2( 1f, 0f ) );
if ( (_leftSpawnEdge || _spawnEdge != Direction.Right) && X + halfW > Arena.WIDTH - Arena.WALL_SIZE )
return WallImpact( new Vector2( -1f, 0f ) );
if ( (_leftSpawnEdge || _spawnEdge != Direction.Down) && Y - halfH < Arena.WALL_SIZE )
return WallImpact( new Vector2( 0f, 1f ) );
if ( (_leftSpawnEdge || _spawnEdge != Direction.Up) && Y + halfH > Arena.HEIGHT - Arena.WALL_SIZE )
return WallImpact( new Vector2( 0f, -1f ) );
if ( _leftSpawnEdge )
return false;
// Still in the spawn grace: has the square cleared its emit edge yet?
bool touchingSpawnEdge = _spawnEdge switch
{
Direction.Left => X - halfW < Arena.WALL_SIZE,
Direction.Right => X + halfW > Arena.WIDTH - Arena.WALL_SIZE,
Direction.Down => Y - halfH < Arena.WALL_SIZE,
_ => Y + halfH > Arena.HEIGHT - Arena.WALL_SIZE,
};
if ( !touchingSpawnEdge )
{
_leftSpawnEdge = true; // in flight — from now on the spawn edge bursts like any wall
return false;
}
// Overlapping its emit edge without ever having cleared it (a steep angle skimming along
// it): keep the lenient path — never burst against its own source, just quietly despawn
// once it has drifted ALL the way back out. The edge blockers mask the sliver rendered
// outside the arena meanwhile.
bool fullyOutside = _spawnEdge switch
{
Direction.Left => X + halfW < Arena.WALL_SIZE,
Direction.Right => X - halfW > Arena.WIDTH - Arena.WALL_SIZE,
Direction.Down => Y + halfH < Arena.WALL_SIZE,
_ => Y - halfH > Arena.HEIGHT - Arena.WALL_SIZE,
};
if ( fullyOutside )
Dead = true;
return Dead;
}
private bool WallImpact( Vector2 inwardNormal )
{
ClampToBounds( X, Y ); // snap flush so the burst spawns on the wall surface
Impact( inwardNormal );
return true;
}
private void Impact( Vector2 normal )
{
Dead = true;
if ( _spraysOnImpact )
Stage.AddBackgroundParticleBurst( Pos, normal );
}
/// <summary>Besides the base transform push, maintain and lay out the trail: record every grid
/// cell the square vacated since last frame (interpolated, so a fast square still paints a
/// CONTINUOUS streak instead of dashes), then position each segment sprite by its LOCAL offset
/// from the current cell — fading linearly toward the tail, or held at uniform opacity when the
/// trail's fade is off.</summary>
public override void SyncTransform()
{
base.SyncTransform();
if ( _trailSprites is null ) return;
Vector2 cell = SnapToCell( Pos );
if ( !_hasCell )
{
_hasCell = true;
_lastCell = cell;
}
else if ( cell != _lastCell )
{
// One step per particle-size along the move keeps consecutive segments edge-to-edge.
Vector2 delta = cell - _lastCell;
int steps = Math.Max( 1, (int)MathF.Ceiling( delta.Length / MathF.Max( 1f, Width ) ) );
for ( int k = 0; k < steps; k++ )
PushTrailCell( _lastCell + delta * (k / (float)steps) ); // excludes the current cell — the head occupies it
_lastCell = cell;
}
for ( int i = 0; i < _trailSprites.Length; i++ )
{
var segment = _trailSprites[i];
if ( i >= _trailCount )
{
segment.Color = Color.Transparent;
continue;
}
Vector2 p = _trailCells[(_trailHead - i + _trailLength * 2) % _trailLength];
var go = segment.GameObject;
go.LocalPosition = new Vector3( p.x - cell.x, p.y - cell.y, go.LocalPosition.z );
float alpha = _trailFade ? _trailColor.a * (1f - (i + 1f) / (_trailLength + 1f)) : _trailColor.a;
segment.Color = _trailColor.WithAlpha( alpha );
}
}
/// <summary>The rendered pixel-grid centre for a logical position (same corner-aligned snap as
/// <see cref="Entity2D.SyncTransform"/>, so trail cells land exactly where the head rendered).</summary>
private Vector2 SnapToCell( Vector2 raw )
{
RectF r = GetPixelRect( raw.x, raw.y );
return new Vector2( r.Left + Width * 0.5f, r.Bottom + Height * 0.5f );
}
private void PushTrailCell( Vector2 raw )
{
Vector2 p = SnapToCell( raw );
// Interpolation can land two steps in the same snapped cell — don't double-record it.
if ( _trailCount > 0 && p == _trailCells[_trailHead] ) return;
_trailHead = (_trailHead + 1) % _trailLength;
_trailCells[_trailHead] = p;
if ( _trailCount < _trailLength ) _trailCount++;
}
}