Level/LevelSpawner.cs

A component that spawns brick GameObjects into the Playfield according to either a fixed Classic layout or a LevelResource. It maps single-character symbols to palette entries (prefab, tint, score), positions and optionally rescales bricks, supports a weekly randomized mix, and animates spawn timing per row.

File Access
using System;
using Sandbox;

namespace Breakout;

/// <summary>
/// One row of the palette. A single character symbol used in a level maps to this brick prefab, plus the tint and score to give it.
/// </summary>
public struct BlockPaletteEntry
{
	[KeyProperty] public string Symbol { get; set; }

	[KeyProperty] public GameObject Prefab { get; set; }

	[KeyProperty] public Color Tint { get; set; }

	[KeyProperty] public int Score { get; set; }
}

/// <summary>
/// Builds a board of bricks. It can spawn the fixed Classic wall, or lay out any .level resource.
/// A level is just rows of characters; each character is looked up in the Palette to find which
/// brick prefab to place (and its tint and score). Weekly mode keeps a level's shape but re-rolls
/// each brick's type from a weighted table.
/// </summary>
[Title( "Level Spawner" ), Category( "Breakout" ), Icon( "grid_on" )]
public sealed class LevelSpawner : Component
{
	public Playfield Playfield { get; set; }

	[Property] public List<BlockPaletteEntry> Palette { get; set; } = new();
	[Property, Group( "Juice" )] public float SpawnRowDelay { get; set; } = 0.045f;
	[Property, Group( "Grid" )] public float SideMargin { get; set; } = 8f;

	private const int ClassicColumns = 14;
	private const float ClassicCellWidth = 54.857f;
	private const float ClassicCellHeight = 30f;
	private const float ClassicTopOffset = 80f;

	// The 8 rows of the authentic Classic wall, top-to-bottom (two rows each): Red, Orange, Green, Yellow.
	// SpawnClassic() repeats each colour across ClassicColumns to build the full brick wall; the palette
	// maps these to score/tint (R highest ... Y lowest), so the harder-to-reach top rows are worth most.
	private static readonly char[] ClassicRowColours = { 'R', 'R', 'O', 'O', 'G', 'G', 'Y', 'Y' };

	private readonly List<GameObject> spawned = new();

	protected override void OnEnabled()
	{
		Playfield ??= Scene.Get<Playfield>();
	}

	/// <summary>
	/// Spawns the fixed 14-column authentic Classic brick wall (red at the top down to yellow).
	/// </summary>
	public void SpawnClassic()
	{
		var layout = new List<string>( ClassicRowColours.Length );
		foreach ( var colour in ClassicRowColours )
			layout.Add( new string( colour, ClassicColumns ) );

		SpawnCore( layout, ClassicCellWidth, ClassicCellHeight, ClassicTopOffset, null, int.MaxValue );
	}

	/// <summary>
	/// Lays out the given level exactly as authored.
	/// </summary>
	public void Spawn( LevelResource level ) => SpawnFromResource( level, null );

	/// <summary>
	/// Lays out the given level but re-rolls each brick's type from a weighted table using seed (used by Weekly mode). maxExpands caps how many expand-pickup bricks it may place.
	/// </summary>
	public void Spawn( LevelResource level, int seed, int maxExpands ) => SpawnFromResource( level, new System.Random( seed ), maxExpands );

	private void SpawnFromResource( LevelResource level, System.Random weeklyRng, int maxExpands = int.MaxValue )
	{
		if ( level?.Layout is null )
		{
			Clear();
			return;
		}

		SpawnCore( level.Layout, level.CellWidth, level.CellHeight, level.TopOffset, weeklyRng, maxExpands );
	}

	private void SpawnCore( IReadOnlyList<string> layout, float levelCellWidth, float levelCellHeight, float topOffset, System.Random weeklyRng, int maxExpands )
	{
		Clear();

		if ( Playfield is null || layout is null || layout.Count == 0 )
			return;

		int columns = layout.Max( row => row?.Length ?? 0 );
		if ( columns == 0 )
			return;

		int minCol = columns, maxCol = -1;
		foreach ( var scan in layout )
		{
			if ( string.IsNullOrEmpty( scan ) )
				continue;

			for ( int c = 0; c < scan.Length; c++ )
			{
				if ( IsEmpty( scan[c] ) )
					continue;

				if ( c < minCol ) minCol = c;
				if ( c > maxCol ) maxCol = c;
			}
		}

		if ( maxCol < 0 )
			return;

		float cellHeight = levelCellHeight > 0f ? levelCellHeight : Playfield.CellHeight;

		// Work out the widest each cell can be while still fitting inside the walls. contentReach
		// is how far the filled bricks stick out from the middle column (measured in cells);
		// dividing the available half width by that reach gives the largest a cell can be before
		// it would spill past the edges. We use whichever is smaller: the requested size or that limit.
		float desiredCellWidth = levelCellWidth > 0f ? levelCellWidth : Playfield.CellWidth;
		float contentReach = MathF.Max( columns * 0.5f - minCol, (maxCol + 1) - columns * 0.5f );
		contentReach = MathF.Max( contentReach, 0.5f );
		float maxCellWidth = MathF.Max( 1f, (Playfield.Width * 0.5f - SideMargin) / contentReach );
		float cellWidth = MathF.Min( desiredCellWidth, maxCellWidth );
		bool shrunk = cellWidth < desiredCellWidth - 0.01f;

		// Center the grid across the play area, starting topOffset below the top wall.
		float startX = Playfield.CenterX - (columns * cellWidth * 0.5f) + (cellWidth * 0.5f);
		float startZ = Playfield.TopZ - topOffset;

		// If the level asked for custom cell sizes, or we had to shrink to fit, scale each brick
		// prefab to match its cell. The 0.92 / 0.88 factors leave a thin gap between bricks.
		bool customCells = levelCellWidth > 0f || levelCellHeight > 0f;
		bool rescale = customCells || shrunk;
		var brickScale = new Vector3(
			(cellWidth / Playfield.CellWidth) * (levelCellWidth > 0f ? 0.92f : 1f),
			1f,
			levelCellHeight > 0f ? (cellHeight / Playfield.CellHeight) * 0.88f : 1f );

		int expandPlaced = 0;

		for ( int r = 0; r < layout.Count; r++ )
		{
			var row = layout[r];
			if ( string.IsNullOrEmpty( row ) )
				continue;

			for ( int c = 0; c < row.Length; c++ )
			{
				if ( IsEmpty( row[c] ) )
					continue;

				char symbol = row[c];
				// Weekly mode keeps the base level's shape (which cells are filled) but re-rolls every
				// brick's type from WeeklyMix, so each week's board is a fresh remix of an existing layout.
				if ( weeklyRng is not null )
				{
					symbol = WeeklySymbol( weeklyRng );

					// Cap paddle-expand bricks per board so a lucky roll can't stack too many.
					if ( symbol == 'E' && expandPlaced >= maxExpands )
						symbol = '1';
				}

				if ( !TryGetEntry( symbol, out var entry ) || entry.Prefab is null )
					continue;

				var position = new Vector3( startX + c * cellWidth, Playfield.PlayY, startZ - r * cellHeight );
				var config = new CloneConfig( new Transform( position ), startEnabled: false );

				var block = entry.Prefab.Clone( config );
				ApplyEntry( block, entry );

				if ( rescale )
					block.LocalScale = brickScale;

				// Each row gets a slightly bigger delay so the wall drops in row by row.
				var spawnIn = block.Components.Create<BlockSpawnIn>();
				spawnIn.Delay = r * SpawnRowDelay;

				block.Enabled = true;
				spawned.Add( block );

				if ( symbol == 'E' )
					expandPlaced++;
			}
		}
	}

	private bool TryGetEntry( char symbol, out BlockPaletteEntry entry )
	{
		var key = symbol.ToString();
		foreach ( var e in Palette )
		{
			if ( e.Symbol == key )
			{
				entry = e;
				return true;
			}
		}

		entry = default;
		return false;
	}

	private static void ApplyEntry( GameObject go, BlockPaletteEntry entry )
	{
		if ( entry.Score <= 0 )
			return;

		var block = go.Components.Get<Block>( FindMode.EverythingInSelfAndDescendants );
		if ( block is null )
			return;

		block.ScoreValue = entry.Score;
		block.HealthyTint = entry.Tint;
	}

	// Weighted spawn table for Weekly mode as (block symbol, weight) pairs; weights sum to 100 so they
	// read as percentages. WeeklySymbol() rolls against this to pick each brick's type: '1' normal,
	// '2' multi-hit, 'P' multiball, 'E' expand, 'X' exploding, 'S' fast ball, 'B' big ball, 'F' fireball.
	private static readonly (char Symbol, int Weight)[] WeeklyMix =
	{
		('1', 56), ('2', 16), ('P', 8), ('E', 6), ('X', 6), ('S', 3), ('B', 3), ('F', 2)
	};

	// Pick a random block type from WeeklyMix, where a bigger weight means a bigger chance. Roll a
	// number up to the total weight, then step through the list until that roll is used up.
	private static char WeeklySymbol( System.Random rng )
	{
		int total = 0;
		foreach ( var e in WeeklyMix )
			total += e.Weight;

		int roll = rng.Next( total );
		foreach ( var (symbol, weight) in WeeklyMix )
		{
			if ( roll < weight )
				return symbol;
			roll -= weight;
		}

		return '1';
	}

	private static bool IsEmpty( char symbol ) => symbol is ' ' or '.' or '0';

	/// <summary>
	/// Removes every brick this spawner created, clearing the board.
	/// </summary>
	public void Clear()
	{
		foreach ( var go in spawned )
		{
			if ( go.IsValid() )
				go.Destroy();
		}

		spawned.Clear();
	}
}