Stages/StageBase.cs
using System;
using System.Collections.Generic;
namespace BlockParty;
/// <summary>
/// Port of the original <c>BaseStage</c> concept: a self-contained game state (title,
/// gameplay, score, ...) that owns its entities. Stages are plain classes (not Components)
/// owned by <see cref="GameManager"/>; each spawns its entities under a dedicated
/// <see cref="Root"/> GameObject that is destroyed when the stage exits.
/// </summary>
public abstract class StageBase
{
public GameManager Manager { get; }
public Scene Scene => Manager.Scene;
/// <summary>Parent GameObject for everything this stage spawns; cleared on exit.</summary>
public GameObject Root { get; private set; }
/// <summary>Whether the retro shader should quantize the frame to the virtual pixel grid while
/// this stage is active. Only actual gameplay (GameStage, menu closed) quantizes; every menu /
/// editor / score stage keeps crisp hi-res text. The tube look (scanlines/curvature/vignette)
/// stays on either way. See RetroArcadePostProcess.</summary>
public virtual bool WantsQuantize => false;
protected readonly List<Entity2D> Entities = new();
/// <summary>Cosmetic backdrop blocks, grouped by depth layer (collision is per-layer).</summary>
private readonly List<List<BackgroundBlock>> _bgLayers = new();
/// <summary>The playfield object the alternate-pattern renderers hang off, and those renderers —
/// kept so <see cref="RefreshAlternatePlayfield"/> can re-point them without rebuilding anything.</summary>
private GameObject _playfield;
private readonly List<PlayfieldPatternRenderer> _alternatePlayfields = new();
private readonly List<SpriteRenderer> _edgeBlockers = new();
protected StageBase( GameManager manager )
{
Manager = manager;
}
public void Enter()
{
Root = new GameObject();
Root.Name = GetType().Name;
// The out-of-bounds colour is per-stage (a game level can recolour it; menus/score use the
// default). Push it to the camera so the pillarbox matches this stage's edge-blocker masks.
RefreshClearColor();
SpawnPlayfield(); // before OnEnter so the field sits behind anything the stage spawns
if ( CoveredBackground )
SpawnCoveredBackdrop();
OnEnter();
// Edge blockers tuck a WALL_SIZE strip UNDER the arena walls to mask elements poking past the
// edge (laser overshoot, drifting blocks). "Covered" stages (score / leaderboard) have no walls
// and nothing that pokes out, so the blockers' inward overlap would instead paint a clear-colour
// ring just inside the dark backdrop — revealed as a snap when the transition square lifts. Skip
// them: the dark backdrop fills the play area and the camera clear fills the pillarbox.
if ( !CoveredBackground )
SpawnEdgeBlockers(); // after OnEnter so the masks sit nearer than everything the stage added
}
// ── per-stage palette hooks ─────────────────────────────────────────────────────────────────────
// All default to the classic look; only GameStage overrides them (from the level definition), so
// the score screen and every menu keep the defaults. Read whatever context they need lazily, since
// SpawnPlayfield / the camera colour run in Enter() BEFORE the stage's OnEnter.
/// <summary>The out-of-bounds colour: the camera pillarbox clear and the edge-blocker masks.</summary>
protected virtual Color EffectiveClearColor => Manager.ClearColor;
/// <summary>Re-apply the live out-of-bounds colour to both surfaces that display it.</summary>
protected void RefreshClearColor()
{
Color color = EffectiveClearColor;
if ( Manager.Camera.IsValid() )
Manager.Camera.BackgroundColor = color;
Color blockerColor = GammaToLinear( color );
foreach ( var blocker in _edgeBlockers )
blocker.Color = blockerColor;
}
/// <summary>Sprite resource drawn as the play-area checkerboard field.</summary>
protected virtual string PlayfieldSprite => "sprites/background.sprite";
/// <summary>Multiplicative tint for the checkerboard field (white = the baked colours as-is).</summary>
protected virtual Color PlayfieldTint => Color.White;
protected virtual PlayfieldPattern PlayfieldPatternKind => PlayfieldPattern.Checker;
protected virtual int PlayfieldCellScale => 1;
protected virtual IReadOnlyList<string> PlayfieldPatternRows => null;
protected virtual Color PlayfieldPatternColor => new( 0f, 189f / 255f, 196f / 255f );
protected virtual Color? PlayfieldPatternSecondColor => null;
protected virtual bool AlternatePlayfieldEnabled => false;
protected virtual Color AlternatePlayfieldColor => PlayfieldPatternColor;
protected virtual Color? AlternatePlayfieldSecondColor => null;
protected virtual PlayfieldPattern AlternatePlayfieldPatternKind => PlayfieldPattern.Checker;
protected virtual int AlternatePlayfieldCellScale => 1;
protected virtual IReadOnlyList<string> AlternatePlayfieldPatternRows => null;
protected virtual IReadOnlyList<RectF> AlternatePlayfieldRects => null;
/// <summary>Base colour the three drifting background-block layers derive their stepped shades
/// from; null keeps the default teal shades. Ignored when <see cref="BackgroundBlockColors"/> is
/// non-null.</summary>
protected virtual Color? BackgroundBlockBaseColor => null;
/// <summary>Explicit colours (front → back, exactly three) for the drifting background-block
/// layers; takes precedence over <see cref="BackgroundBlockBaseColor"/>. Null uses the derived /
/// default shades.</summary>
protected virtual IReadOnlyList<Color> BackgroundBlockColors => null;
/// <summary>Per-level size multiplier for the drifting background blocks.</summary>
protected virtual float BackgroundBlockScale => 1f;
protected virtual float BackgroundBlockDensity => 1f;
protected virtual float BackgroundBlockOpacity => 1f;
protected virtual float BackgroundDriftSpeed => 1f;
protected virtual BackgroundDriftBias BackgroundBlockDriftBias => BackgroundDriftBias.None;
/// <summary>Original <c>Swatches.Fade</c> colour (the game's "BLACK", 58,64,76) — the colour of
/// the stage-transition square and the "covered" backdrop, so the two are visually identical.</summary>
public static readonly Color FadeColor = new Color( 58f / 255f, 64f / 255f, 76f / 255f );
/// <summary>When true, the stage paints a full <see cref="FadeColor"/> square over the play area
/// as its backdrop (the score tally + leaderboard). This makes the stage-transition square appear
/// to "stay" covering the screen across these stages — it grows to cover the game on game-over,
/// then the score/leaderboard simply live on the same dark fill until we shrink it away to reveal
/// the menu (see <see cref="GameManager.TransitionToStage"/>).</summary>
public virtual bool CoveredBackground => false;
/// <summary>Paint the dark <see cref="FadeColor"/> square over the play area (the covered-stage
/// backdrop). Sits just in front of the playfield so it hides it, and behind all stage content.</summary>
private void SpawnCoveredBackdrop()
{
var go = CreateChild( "CoveredBackdrop" );
go.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f, Globals.DepthToZ( Globals.DEPTH_BACKDROP ) );
var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", new Vector2( Arena.WIDTH, Arena.HEIGHT ), "idle" );
// Tint a sprite is consumed as LINEAR; convert the gamma fade colour so the world quad shows
// the exact same on-screen colour as the (gamma) UI transition square (see GammaToLinear).
sr.Color = GammaToLinear( FadeColor );
}
/// <summary>The static dithered teal play-area square. Spawned for EVERY stage (so the game
/// always reads as a fixed 240x240 square, even in menus) and created first, so it sits behind
/// the cosmetic blocks and all gameplay. The dark camera clear colour shows outside it.</summary>
private void SpawnPlayfield()
{
var go = CreateChild( "Playfield" );
if ( PlayfieldPatternKind != PlayfieldPattern.Checker || PlayfieldCellScale != 1 || PlayfieldPatternSecondColor.HasValue )
{
Color light = PlayfieldPatternColor;
Color dark = PlayfieldPatternSecondColor ?? StepShade( light, 235f / 255f );
var renderer = go.Components.Create<PlayfieldPatternRenderer>();
renderer.Setup( PlayfieldPatternKind, PlayfieldCellScale, PlayfieldPatternRows, GammaToLinear( light ), GammaToLinear( dark ) );
}
else
{
go.WorldPosition = new Vector3( Arena.WIDTH / 2f, Arena.HEIGHT / 2f, Globals.DepthToZ( Globals.DEPTH_PLAYFIELD ) );
var sr = SpriteLayer.Add( go, PlayfieldSprite, new Vector2( Arena.WIDTH, Arena.HEIGHT ), "idle" );
// White = the baked checkerboard shows as-is; a level recolours it via a neutral variant + tint.
sr.Color = PlayfieldTint;
}
// The old renderers died with the previous Playfield object; take the new one as their parent.
_playfield = go;
_alternatePlayfields.Clear();
SyncAlternatePlayfield();
}
/// <summary>Re-render ONLY the alternate-pattern regions, reusing the existing renderers (their
/// <see cref="PlayfieldPatternRenderer.Setup"/> is idempotent — it just rewrites the attributes and
/// the quad), so nothing is created or destroyed unless the rect COUNT changed.
///
/// Unlike <see cref="RefreshEnvironment"/> this leaves the base playfield and the drifting background
/// blocks alone. That matters because the level editor calls this every frame while an alt-pattern
/// rect is being dragged, resized or nudged: a full refresh would re-roll every background block's
/// spawn position (scrambling the backdrop) and churn ~20 GameObjects per frame.</summary>
protected void RefreshAlternatePlayfield()
{
// No playfield yet (or it was destroyed under us) — nothing to reparent to, so rebuild the lot.
if ( !_playfield.IsValid() ) { RefreshEnvironment(); return; }
SyncAlternatePlayfield();
}
/// <summary>Point one pattern renderer at each alternate-pattern rect, adding/trimming renderers only
/// when the rect count changes. Degenerate rects are skipped; nothing renders while the feature is off.</summary>
private void SyncAlternatePlayfield()
{
int used = 0;
if ( AlternatePlayfieldEnabled && AlternatePlayfieldRects is { Count: > 0 } rects )
{
Color alternateLight = GammaToLinear( AlternatePlayfieldColor );
Color alternateDark = GammaToLinear( AlternatePlayfieldSecondColor
?? StepShade( AlternatePlayfieldColor, 235f / 255f ) );
foreach ( RectF rect in rects )
{
if ( rect.Width <= 0f || rect.Height <= 0f ) continue;
if ( used == _alternatePlayfields.Count )
{
var alternateGo = new GameObject { Name = "AlternatePlayfield" };
alternateGo.SetParent( _playfield );
_alternatePlayfields.Add( alternateGo.Components.Create<PlayfieldPatternRenderer>() );
}
_alternatePlayfields[used].Setup( AlternatePlayfieldPatternKind, AlternatePlayfieldCellScale,
AlternatePlayfieldPatternRows, alternateLight, alternateDark, rect, depthOrder: 1 );
used++;
}
}
for ( int i = _alternatePlayfields.Count - 1; i >= used; i-- )
{
_alternatePlayfields[i].GameObject?.Destroy();
_alternatePlayfields.RemoveAt( i );
}
}
/// <summary>Quads masking the area just OUTSIDE the 240x240 square on every edge, colour-
/// matched to the camera clear and drawn nearer than gameplay elements but behind the walls. They are invisible to the
/// player (identical colour to the background outside the arena) but occlude anything that pokes a
/// little past the arena edge — the squid laser overshooting the wall, or a cosmetic background
/// block drifting out — so the game always reads as a clean fixed square.
///
/// All four edges are covered: with the current camera only the L/R pillarbox is ever visible (ortho
/// height is locked to the arena height), but a portrait/narrow viewport could instead show area
/// above/below, so we mask top and bottom too.</summary>
private void SpawnEdgeBlockers()
{
_edgeBlockers.Clear();
// Large enough to span any reasonable letterbox / pillarbox region. The inner edge tucks under
// the arena wall, so these can be simple outside quads.
const float SPAN = 4000f;
// Tuck the inner edge WALL_SIZE px UNDER the solid wall. The bands sit just BEHIND the outer walls
// (DEPTH_BLOCKER < DEPTH_ARENA_WALL) so the wall draws over this overlap (never touching the play
// area) while removing the shared seam that screenshake would otherwise flicker the playfield through.
float z = Globals.DEPTH_BLOCKER;
// The camera clear paints the pillarbox with ClearColor's gamma value directly, but a sprite
// TINT is consumed by the shader as a LINEAR value (SpriteRenderer packs it via Color.ToRgbe
// with no gamma step, and Color floats hold gamma/sRGB values). Feeding the gamma value as-is
// makes the band render noticeably brighter than the clear, so the mask is visible. Convert
// gamma -> linear so the band displays the SAME colour as the clear and stays invisible.
Color color = GammaToLinear( EffectiveClearColor );
float cx = Arena.WIDTH / 2f, cy = Arena.HEIGHT / 2f;
float halfSpan = SPAN / 2f;
AddBlocker( new Vector2( Arena.WALL_SIZE - halfSpan, cy ), new Vector2( SPAN, SPAN ), z, color ); // left
AddBlocker( new Vector2( Arena.WIDTH - Arena.WALL_SIZE + halfSpan, cy ), new Vector2( SPAN, SPAN ), z, color ); // right
AddBlocker( new Vector2( cx, Arena.HEIGHT - Arena.WALL_SIZE + halfSpan ), new Vector2( SPAN, SPAN ), z, color ); // top
AddBlocker( new Vector2( cx, Arena.WALL_SIZE - halfSpan ), new Vector2( SPAN, SPAN ), z, color ); // bottom
}
private void AddBlocker( Vector2 center, Vector2 size, float z, Color color )
{
var go = CreateChild( "EdgeBlocker" );
go.WorldPosition = new Vector3( center.x, center.y, z );
var sr = SpriteLayer.Add( go, "sprites/pixel.sprite", size, "idle" );
// NOT opaque, deliberately: making the blockers depth-write (to stop overlay-effect pixels
// leaking into the pillarbox) grew the OPAQUE sprite batch's bounds past the arena, which
// flipped the bounds-driven draw order between the engine's opaque/transparent sprite
// batches and stomped every translucent sprite (blocks under glass, invisible goo/fences —
// the e4dd570 regression). Engine 98907a9109 gave sprite batches proper pass flags, so this
// can be sr.Opaque = true again together with the OPAQUE_DEFAULT flip (see SpriteLayer.Add).
sr.Color = color;
_edgeBlockers.Add( sr );
}
/// <summary>Standard sRGB gamma->linear transfer (matches the engine's internal Color.ToLinear,
/// which isn't accessible from game code). Used so a sprite tint — consumed as a linear value —
/// renders the same on-screen colour as a gamma-space clear/background colour.</summary>
protected static Color GammaToLinear( Color c )
{
static float Ch( float v ) => v <= 0.04045f ? v / 12.92f : MathF.Pow( ( v + 0.055f ) / 1.055f, 2.4f );
return new Color( Ch( c.r ), Ch( c.g ), Ch( c.b ), c.a );
}
/// <summary>Scale a colour's RGB toward black by <paramref name="factor"/> (alpha preserved) — the
/// per-layer darkening step for the drifting background blocks.</summary>
private static Color StepShade( Color c, float factor ) => new Color( c.r * factor, c.g * factor, c.b * factor, c.a );
public void Exit()
{
OnExit();
Root?.Destroy();
Root = null;
_playfield = null;
_alternatePlayfields.Clear();
_edgeBlockers.Clear();
Entities.Clear();
_bgParticles.Clear(); // their GameObjects died with Root
}
protected virtual void OnEnter() { }
protected virtual void OnExit() { }
/// <summary>When true, <see cref="GameManager"/> freezes the fixed-step simulation for this
/// stage (used by the in-game options overlay) while still pumping <see cref="TickMenu"/>.</summary>
public virtual bool BlocksSimulation => false;
/// <summary>Called once per rendered frame while <see cref="BlocksSimulation"/> is true, in
/// place of the simulation, so a paused-but-open menu can still handle navigation input.</summary>
public virtual void TickMenu( float dt ) { }
/// <summary>Advance the simulation by one fixed step. Override to drive entities in the right order.</summary>
public virtual void Tick( float dt )
{
foreach ( var e in Entities )
{
if ( !e.Dead )
e.Tick( dt );
}
Entities.RemoveAll( e =>
{
if ( e.Dead ) { e.GameObject?.Destroy(); return true; }
return false;
} );
TickBackgroundBlocks( dt );
TickBackgroundParticles( dt );
}
/// <summary>Push logical positions into transforms once per rendered frame.</summary>
public virtual void SyncTransforms()
{
foreach ( var e in Entities )
e.SyncTransform();
SyncBackgroundBlocks();
SyncBackgroundParticles();
}
// --- cosmetic background blocks -----------------------------------------------------
// Faceless darker-teal squares that drift around behind everything. Port of the original
// 3-layer backdrop (TitleStage + GameStage only). Stages that override Tick/SyncTransforms
// without calling base must invoke TickBackgroundBlocks/SyncBackgroundBlocks themselves.
/// <summary>Spawn the original's 3-layer backdrop: 6/5/4 blocks of decreasing size, in three
/// teal shades, sitting just behind the play depth so the teal clear shows through layer 0.</summary>
protected void SpawnBackgroundBlocks()
{
// Stepped darker teals, front -> back (back darkest). Kept clearly BELOW the checkerboard
// field's two shades (TEAL 189 / DARK_TEAL_0 182) and well-spaced from each other so the
// three layers read as distinct moving shapes rather than blending into the dither. A level
// may supply all three colours explicitly, or a single base colour (stepped-darker from it).
var colors = BackgroundBlockColors is { Count: 3 } explicitColors
? new[] { GammaToLinear( explicitColors[0] ), GammaToLinear( explicitColors[1] ), GammaToLinear( explicitColors[2] ) }
: BackgroundBlockBaseColor is Color baseColor
? new[]
{
StepShade( GammaToLinear( baseColor ), 1f ),
StepShade( GammaToLinear( baseColor ), 0.9f ),
StepShade( GammaToLinear( baseColor ), 0.8f ),
}
: new[]
{
new Color( 0f, 150f / 255f, 157f / 255f ),
new Color( 0f, 135f / 255f, 142f / 255f ),
new Color( 0f, 120f / 255f, 127f / 255f ),
};
float opacity = Math.Clamp( BackgroundBlockOpacity, 0f, 1f );
for ( int i = 0; i < colors.Length; i++ )
colors[i] = colors[i].WithAlpha( colors[i].a * opacity );
for ( int layer = 0; layer < 3; layer++ )
{
var siblings = new List<BackgroundBlock>();
_bgLayers.Add( siblings );
int numBlocks = Math.Max( 1, (int)MathF.Round( (6 - layer) * Math.Clamp( BackgroundBlockDensity, 0.25f, 2f ) ) );
float size = (50f - 8f * layer) * Math.Clamp( BackgroundBlockScale, 0.25f, 1.5f );
// Layer 0 nearest; each deeper layer steps back enough to keep the decorative sheets distinct
// while staying well above the playfield.
int depth = Globals.DEPTH_BACKGROUND - layer * 3;
for ( int j = 0; j < numBlocks; j++ )
{
var go = CreateChild( $"BgBlock_{layer}_{j}" );
var b = go.Components.Create<BackgroundBlock>();
b.Depth = depth;
// Spawn fully inside the arena (inset by half the block size). The blockers mask any
// block that later drifts out, but on the stage's very first frame the blockers
// aren't drawn yet, so a block straddling the edge would flash outside for one frame.
float half = size / 2f;
var candidate = new Vector2( Rng.BackgroundFloat( half, Arena.WIDTH - half ), Rng.BackgroundFloat( half, Arena.HEIGHT - half ) );
if ( !TryFindBackgroundSpawn( candidate, size, siblings, out var spawn ) )
{
go.Destroy();
continue;
}
b.Pos = spawn;
b.Setup( new Vector2( size, size ), colors[layer], layer, siblings, BackgroundDriftSpeed, BackgroundBlockDriftBias );
b.CreateVisuals( j ); // per-block z nudge avoids z-fighting on momentary overlap
siblings.Add( b );
}
}
}
private static bool TryFindBackgroundSpawn( Vector2 start, float size, IReadOnlyList<BackgroundBlock> siblings, out Vector2 spawn )
{
const float clearance = 4f;
float half = size / 2f;
int min = (int)MathF.Ceiling( Arena.WALL_SIZE + half );
int max = (int)MathF.Floor( Arena.WIDTH - Arena.WALL_SIZE - half );
int span = max - min + 1;
if ( span <= 0 ) { spawn = default; return false; }
int startX = Math.Clamp( (int)MathF.Round( start.x ), min, max );
int startY = Math.Clamp( (int)MathF.Round( start.y ), min, max );
for ( int yOffset = 0; yOffset < span; yOffset++ )
{
float y = min + (startY - min + yOffset) % span;
for ( int xOffset = 0; xOffset < span; xOffset++ )
{
float x = min + (startX - min + xOffset) % span;
bool clear = true;
foreach ( var sibling in siblings )
{
if ( MathF.Abs( x - sibling.X ) < size + clearance && MathF.Abs( y - sibling.Y ) < size + clearance )
{
clear = false;
break;
}
}
if ( clear ) { spawn = new Vector2( x, y ); return true; }
}
}
spawn = default;
return false;
}
protected void TickBackgroundBlocks( float dt )
{
foreach ( var layer in _bgLayers )
foreach ( var b in layer )
b.Tick( dt );
}
/// <summary>Rebuild the checkerboard playfield + drifting background blocks from the CURRENT palette
/// hooks. Normally these are spawned once at <see cref="Enter"/>; the level editor calls this to
/// preview live palette edits (checker + background-block colours). Also re-applies the camera clear.</summary>
protected void RefreshEnvironment()
{
foreach ( var layer in _bgLayers )
foreach ( var b in layer )
b.GameObject?.Destroy();
_bgLayers.Clear();
if ( Root is not null )
{
// Snapshot the children first — destroying while iterating the live collection is unsafe.
var children = new List<GameObject>( Root.Children );
foreach ( var child in children )
if ( child.Name == "Playfield" )
child.Destroy();
}
SpawnPlayfield();
SpawnBackgroundBlocks();
// The background particle emitter re-reads its level settings lazily; drop the live squares
// too so a restored undo snapshot / palette reset starts its ambience clean, like the blocks.
ClearBackgroundParticles();
InvalidateBackgroundParticleSettings();
RefreshClearColor();
}
protected void SyncBackgroundBlocks()
{
foreach ( var layer in _bgLayers )
foreach ( var b in layer )
b.SyncTransform();
}
// --- cosmetic background particles ---------------------------------------------------
// Per-level falling-square ambience (rain / snow / embers — see LevelDef.BackgroundParticlesEnabled):
// squares emitted from one arena edge that die on the first solid they meet, optionally bursting
// into spray. Owned here (not GameStage) so the level editor previews them live while its sim is
// frozen. Stages that override Tick/SyncTransforms without calling base must invoke
// TickBackgroundParticles/SyncBackgroundParticles themselves, like the background blocks.
/// <summary>Live particles: the emitted squares plus their short-lived impact-spray fragments.</summary>
private readonly List<BackgroundParticle> _bgParticles = new();
/// <summary>The solid rects particles die against THIS tick (static obstacles/glass first, then
/// live blocks — never fences), rebuilt once per tick and read by every particle's Tick.</summary>
private readonly List<RectF> _bgParticleSolids = new();
private int _bgParticleStaticSolidCount;
// Reused span-scratch for the spawn-position pick (see TryPickBackgroundParticleSpawn).
private readonly List<(float Start, float End)> _bgParticleBlocked = new();
private BackgroundParticleSettings _bgParticleSettings;
private bool _bgParticleSettingsDirty = true;
private float _bgParticleSpawnCarry;
private int _bgParticleChildOrder;
/// <summary>Hard ceiling on live EMITTED squares so a slow near-horizontal config can't
/// accumulate unbounded (spawning pauses until some die). Spray fragments share the live list
/// but deliberately don't count toward this: they're short-lived and self-bounded (death rate ×
/// count × lifetime), and a heavy splash must never starve the weather itself.</summary>
private const int BG_PARTICLE_CAP = 300;
/// <summary>Resolved emitter settings, or null when the stage's level doesn't enable the feature.
/// Only GameStage / the level editor override this (from their level data); menus stay null.</summary>
protected virtual BackgroundParticleSettings BuildBackgroundParticleSettings() => null;
/// <summary>Static solids the particles collide with AND that mask spawn positions when flush with
/// the emit edge: interior obstacles + glass, never fences (the same set Particle bounces off).</summary>
protected virtual void CollectBackgroundParticleStaticSolids( List<RectF> solids ) { }
/// <summary>Moving solids the particles collide with (live blocks); these never mask spawns.</summary>
protected virtual void CollectBackgroundParticleDynamicSolids( List<RectF> solids ) { }
/// <summary>This tick's shared solid snapshot, read by <see cref="BackgroundParticle.Tick"/>.</summary>
internal IReadOnlyList<RectF> BackgroundParticleSolids => _bgParticleSolids;
/// <summary>Re-read the level's particle settings on the next tick. Cheap — live particles keep
/// flying (unlike <see cref="RefreshEnvironment"/>), so the editor can call it per slider tick
/// without restarting the weather.</summary>
protected void InvalidateBackgroundParticleSettings() => _bgParticleSettingsDirty = true;
protected void TickBackgroundParticles( float dt )
{
if ( _bgParticleSettingsDirty )
{
_bgParticleSettings = BuildBackgroundParticleSettings();
_bgParticleSettingsDirty = false;
}
var settings = _bgParticleSettings;
if ( settings is null )
{
if ( _bgParticles.Count > 0 )
ClearBackgroundParticles();
return;
}
_bgParticleSolids.Clear();
CollectBackgroundParticleStaticSolids( _bgParticleSolids );
_bgParticleStaticSolidCount = _bgParticleSolids.Count;
CollectBackgroundParticleDynamicSolids( _bgParticleSolids );
// Fractional spawn accumulator; capped so a hitch frame can't dump a burst of spawns at once.
// The spawn cap counts only the emitted squares — see BG_PARTICLE_CAP.
int mainCount = 0;
foreach ( var particle in _bgParticles )
if ( !particle.IsBurst )
mainCount++;
_bgParticleSpawnCarry = MathF.Min( _bgParticleSpawnCarry + settings.SpawnRate * dt, 8f );
while ( _bgParticleSpawnCarry >= 1f )
{
_bgParticleSpawnCarry -= 1f;
if ( mainCount < BG_PARTICLE_CAP && SpawnBackgroundParticle( settings ) )
mainCount++;
}
// Index loop on the pre-tick count: an impact burst APPENDS spray fragments mid-iteration;
// they render where they spawned this frame and start ticking next frame.
int existing = _bgParticles.Count;
for ( int i = 0; i < existing; i++ )
{
var p = _bgParticles[i];
if ( !p.Dead )
p.Tick( dt );
}
for ( int i = _bgParticles.Count - 1; i >= 0; i-- )
{
if ( !_bgParticles[i].Dead ) continue;
_bgParticles[i].GameObject?.Destroy();
_bgParticles.RemoveAt( i );
}
}
/// <summary>Emit one square; false when the emit edge is entirely covered by flush solids.</summary>
private bool SpawnBackgroundParticle( BackgroundParticleSettings s )
{
// BACKGROUND stream throughout (like BackgroundBlock): autonomous level ambience must never
// perturb the sim or the player-triggered cosmetic stream.
int size = Rng.BackgroundInt( s.SizeMin, s.SizeMax + 1 );
if ( !TryPickBackgroundParticleSpawn( s, size * 0.5f, out Vector2 spawn ) )
return false;
// Emit direction: straight-inward from the edge, tilted by the authored angle plus this
// particle's random tilt.
Vector2 inward = -Globals.GetVectorForDirection( s.Edge );
float tilt = s.Angle + Rng.BackgroundFloat( -s.AngleRange, s.AngleRange );
Vector2 velocity = Utils.RotateVector( inward, tilt ) * Rng.BackgroundFloat( s.SpeedMin, s.SpeedMax );
var go = CreateChild( "BgParticle" );
var p = go.Components.Create<BackgroundParticle>();
p.Stage = this;
p.Pos = spawn;
p.Size = new Vector2( size, size );
p.Depth = Globals.DEPTH_BACKGROUND_PARTICLE;
p.Setup( velocity, s.Gravity, s.Color, s.Edge, spraysOnImpact: s.ImpactEnabled );
if ( s.TrailLength > 0 )
p.SetupTrail( s.TrailLength, s.TrailColor, s.TrailFade );
// The head sits above its own trail segments (which use childOrders 0..TrailLength-1).
p.CreateVisuals( s.TrailLength + NextBackgroundParticleChildOrder() );
_bgParticles.Add( p );
return true;
}
/// <summary>Pick a uniformly random spawn centre along the emit edge, skipping stretches covered
/// by static solids flush with (or within one max particle size of) that edge — the emission line
/// itself never moves off the true arena edge (deliberately unlike solar sunlight, which walks its
/// source down below ceiling-flush obstacles). False = the edge is entirely covered.</summary>
private bool TryPickBackgroundParticleSpawn( BackgroundParticleSettings s, float half, out Vector2 spawn )
{
spawn = default;
bool horizontal = s.Edge is Direction.Up or Direction.Down; // the along-edge axis is X
float edgeCoord = s.Edge switch
{
Direction.Up => Arena.HEIGHT - Arena.WALL_SIZE,
Direction.Down => Arena.WALL_SIZE,
Direction.Left => Arena.WALL_SIZE,
_ => Arena.WIDTH - Arena.WALL_SIZE,
};
float alongMin = Arena.WALL_SIZE + half;
float alongMax = (horizontal ? Arena.WIDTH : Arena.HEIGHT) - Arena.WALL_SIZE - half;
if ( alongMax <= alongMin )
return false;
// Blocked spans: static solids near enough to the edge that a fresh square would start inside
// them (flush obstacles, or within one max size — an instant-kill spawn reads as a glitch).
_bgParticleBlocked.Clear();
for ( int i = 0; i < _bgParticleStaticSolidCount; i++ )
{
RectF r = _bgParticleSolids[i];
bool nearEdge = s.Edge switch
{
Direction.Up => r.Top >= edgeCoord - s.SizeMax,
Direction.Down => r.Bottom <= edgeCoord + s.SizeMax,
Direction.Left => r.Left <= edgeCoord + s.SizeMax,
_ => r.Right >= edgeCoord - s.SizeMax,
};
if ( !nearEdge ) continue;
float start = (horizontal ? r.Left : r.Bottom) - half;
float end = (horizontal ? r.Right : r.Top) + half;
if ( end > alongMin && start < alongMax )
_bgParticleBlocked.Add( (start, end) );
}
_bgParticleBlocked.Sort( ( a, b ) => a.Start.CompareTo( b.Start ) );
// Total open length, then a second walk maps the rolled offset back to a coordinate.
float total = 0f;
float cursor = alongMin;
foreach ( var (start, end) in _bgParticleBlocked )
{
if ( start > cursor )
total += MathF.Min( start, alongMax ) - cursor;
cursor = MathF.Max( cursor, end );
if ( cursor >= alongMax ) break;
}
if ( cursor < alongMax )
total += alongMax - cursor;
if ( total <= 0f )
return false;
float roll = Rng.BackgroundFloat( 0f, total );
float along = alongMax;
cursor = alongMin;
foreach ( var (start, end) in _bgParticleBlocked )
{
if ( start > cursor )
{
float len = MathF.Min( start, alongMax ) - cursor;
if ( roll <= len ) { along = cursor + roll; cursor = alongMax; break; }
roll -= len;
}
cursor = MathF.Max( cursor, end );
if ( cursor >= alongMax ) break;
}
if ( cursor < alongMax )
along = MathF.Min( cursor + roll, alongMax );
// Fully inside the arena, flush with the emit edge.
float inset = s.Edge switch
{
Direction.Up => edgeCoord - half,
Direction.Down => edgeCoord + half,
Direction.Left => edgeCoord + half,
_ => edgeCoord - half,
};
spawn = horizontal ? new Vector2( along, inset ) : new Vector2( inset, along );
return true;
}
/// <summary>Spray the configured impact fragments along <paramref name="normal"/> (the struck
/// surface's outward normal). Called by a dying <see cref="BackgroundParticle"/> mid-tick; the
/// fragments join the same list and start ticking next frame.</summary>
internal void AddBackgroundParticleBurst( Vector2 pos, Vector2 normal )
{
var s = _bgParticleSettings;
if ( s is null || !s.ImpactEnabled )
return;
int count = Rng.BackgroundInt( s.ImpactCountMin, s.ImpactCountMax + 1 );
for ( int i = 0; i < count; i++ )
{
Vector2 dir = Utils.RotateVector( normal, Rng.BackgroundFloat( -s.ImpactAngleRange, s.ImpactAngleRange ) );
float speed = Rng.BackgroundFloat( s.ImpactSpeedMin, s.ImpactSpeedMax );
int size = Rng.BackgroundInt( s.ImpactSizeMin, s.ImpactSizeMax + 1 );
var go = CreateChild( "BgParticleBurst" );
var p = go.Components.Create<BackgroundParticle>();
p.Stage = this;
// Nudge off the surface so the fragment doesn't render half-buried in it on frame one.
p.Pos = pos + normal * 0.5f;
p.Size = new Vector2( size, size );
p.Depth = Globals.DEPTH_BACKGROUND_PARTICLE;
p.SetupBurst( dir * speed, s.ImpactGravity, s.ImpactColor, Rng.BackgroundFloat( 0.25f, 0.5f ) );
p.CreateVisuals( NextBackgroundParticleChildOrder() );
_bgParticles.Add( p );
}
}
/// <summary>Cycling per-particle z nudge (0..0.7 above the layer) so overlapping translucent
/// squares never share a Z and flicker. Worst-case ceiling — max trail (8) + max nudge (7) —
/// is 1.5 above DEPTH_BACKGROUND_PARTICLE, still under DEPTH_LANE_OVERLAY.</summary>
private int NextBackgroundParticleChildOrder() => _bgParticleChildOrder++ & 7;
protected void SyncBackgroundParticles()
{
foreach ( var p in _bgParticles )
p.SyncTransform();
}
private void ClearBackgroundParticles()
{
foreach ( var p in _bgParticles )
p.GameObject?.Destroy();
_bgParticles.Clear();
_bgParticleSpawnCarry = 0f;
}
/// <summary>Create a child GameObject parented to this stage's root.</summary>
public GameObject CreateChild( string name )
{
var go = new GameObject();
go.Name = name;
go.SetParent( Root );
return go;
}
}