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.
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();
}
}