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;
	}
}