Level/LevelDirector.cs

Component that picks which level to play. It discovers .level resources or uses a manual ArcadeLevels list, provides Weekly and Arcade selection logic, builds a shuffled arcade order, and exposes accessors for current/weekly levels and weekly seed.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
using Sandbox.UI;

namespace Breakout;

/// <summary>
/// Owns which level to play: runtime discovery of all .level resources, the Arcade shuffle
/// order, and the deterministic Weekly selection. BreakoutGame delegates here.
/// </summary>
[Title( "Level Director" ), Category( "Breakout" ), Icon( "playlist_play" )]
public sealed class LevelDirector : Component
{
	/// <summary>
	/// Optional manual override. When empty, the pool is discovered from every .level resource.
	/// </summary>
	[Property] public List<LevelResource> ArcadeLevels { get; set; } = new();

	private List<LevelResource> _discoveredLevels;

	private readonly List<int> arcadeOrder = new();
	private int arcadeOrderPos;
	private int lastArcadeIndex = -1;

	/// <summary>
	/// The pool of levels: the manual ArcadeLevels list if set, otherwise every .level asset in the project.
	/// </summary>
	public IReadOnlyList<LevelResource> Levels
	{
		get
		{
			if ( ArcadeLevels is { Count: > 0 } )
				return ArcadeLevels;

			if ( _discoveredLevels is not { Count: > 0 } )
			{
				_discoveredLevels = ResourceLibrary.GetAll<LevelResource>()
					.OrderBy( l => l.ResourcePath, StringComparer.OrdinalIgnoreCase )
					.ToList();
			}

			return _discoveredLevels;
		}
	}

	/// <summary>
	/// Which level index this week's challenge uses (the same for everyone playing that week).
	/// </summary>
	public int WeeklyLevelIndex => Levels is { Count: > 0 }
		? ((BreakoutGame.WeeklyId % Levels.Count) + Levels.Count) % Levels.Count
		: 0;

	/// <summary>
	/// A per-week, per-level random seed, so the Weekly board's brick mix matches for everyone that week.
	/// </summary>
	public int WeeklySeed( int level ) => BreakoutGame.WeeklyId * 397 + level;

	/// <summary>
	/// The level resource to load for the given mode and level number.
	/// </summary>
	public LevelResource CurrentLevel( GameMode mode, int level )
	{
		var levels = Levels;
		if ( levels is not { Count: > 0 } )
			return null;

		int idx = mode switch
		{
			GameMode.Weekly => WeeklyLevelIndex,
			GameMode.Arcade when arcadeOrder.Count > 0 => arcadeOrder[Math.Clamp( arcadeOrderPos, 0, arcadeOrder.Count - 1 )],
			_ => 0
		};

		return levels[Math.Clamp( idx, 0, levels.Count - 1 )];
	}

	/// <summary>
	/// Starts a fresh Arcade run: forget the previously played board, then shuffle.
	/// </summary>
	public void BeginArcadeRun()
	{
		lastArcadeIndex = -1;
		BuildArcadeOrder();
	}

	/// <summary>
	/// Steps to the next level in the shuffled Arcade order, reshuffling once the list runs out.
	/// </summary>
	public void AdvanceArcadeOrder()
	{
		if ( arcadeOrder.Count == 0 )
		{
			BuildArcadeOrder();
			return;
		}

		lastArcadeIndex = arcadeOrder[Math.Clamp( arcadeOrderPos, 0, arcadeOrder.Count - 1 )];

		arcadeOrderPos++;
		if ( arcadeOrderPos >= arcadeOrder.Count )
			BuildArcadeOrder();
	}

	private void BuildArcadeOrder()
	{
		arcadeOrder.Clear();
		arcadeOrderPos = 0;

		var levels = Levels;
		if ( levels is not { Count: > 0 } )
			return;

		arcadeOrder.AddRange( Enumerable.Range( 0, levels.Count ).OrderBy( _ => Game.Random.Float( 0f, 1f ) ) );

		if ( arcadeOrder.Count > 1 && arcadeOrder[0] == lastArcadeIndex )
			(arcadeOrder[0], arcadeOrder[1]) = (arcadeOrder[1], arcadeOrder[0]);
	}

	public LevelResource GetWeeklyLevel()
	{
		var levels = Levels;
		if ( levels is not { Count: > 0 } )
			return null;
		return levels[Math.Clamp( WeeklyLevelIndex, 0, levels.Count - 1 )];
	}
}