feat(levels): level catalogue with stable ids (slice 7)

LevelResource gains a stable Id and swaps the string ScenePath for an
editor-tracked PackedScene. The old path field was never read and had
rotted - forest_1 pointed at level_forest_5.tscn, which doesn't exist,
and borrowed LEVEL_5_NAME so it would have displayed "VILLAGE - 5".

LevelCatalogue lists levels in campaign order and produces the domain's
Campaign; an authoring mistake fails at boot because Campaign.Of rejects
blank and duplicate ids. Not a domain port - nothing in the domain
resolves a level to a scene.

All 8 scenes now have a resource; campaign keeps the existing 6
(tutorial_1, village_1..5). village_6 and forest_1 are catalogued but
deliberately out of the campaign.

GameManager.LevelScenes stays until slice 10 rewrites GameManager.
This commit is contained in:
2026-07-28 19:28:56 +02:00
parent 330e10880c
commit f991bb99fc
14 changed files with 155 additions and 32 deletions

View File

@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.Linq;
using Godot;
using Godot.Collections;
using Mr.BrickAdventures.Progression;
namespace Mr.BrickAdventures.scripts.Resources;
/// <summary>
/// The authored list of Levels in campaign order, and the bridge from a <see cref="LevelId"/> back to
/// the Godot scene that implements it.
/// </summary>
/// <remarks>
/// Not a domain port. Nothing in the progression domain resolves a Level to a scene — the domain only
/// needs the ordering, which it takes as a <see cref="Campaign"/>. Scene lookup is wanted solely by
/// the composition root, which is engine-side anyway.
/// </remarks>
[GlobalClass]
public partial class LevelCatalogue : Resource
{
/// <summary>
/// Levels in campaign order. A Level absent from this list is not reachable, even if its
/// <see cref="LevelResource"/> exists.
/// </summary>
[Export]
public Array<LevelResource> Levels { get; set; } = [];
/// <summary>
/// The ordering the domain works from. Throws if the authored data is inconsistent — a blank or
/// duplicated id cannot produce a <see cref="Campaign"/>, so a mistake here fails at boot rather
/// than corrupting a save later.
/// </summary>
public Campaign ToCampaign()
{
var ids = new List<LevelId>();
for (var i = 0; i < Levels.Count; i++)
{
var level = Levels[i];
if (level == null)
throw new System.InvalidOperationException(
$"LevelCatalogue has an empty slot at index {i}.");
ids.Add(LevelId.Of(level.Id));
}
return Campaign.Of(ids);
}
public LevelResource Find(LevelId id) =>
Levels.FirstOrDefault(level => level != null && level.Id == id.Value);
public PackedScene SceneFor(LevelId id) => Find(id)?.Scene;
}

View File

@@ -2,12 +2,33 @@ using Godot;
namespace Mr.BrickAdventures.scripts.Resources;
/// <summary>
/// One authored Level: its stable id, its display name key, and the scene to load.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Id"/> is what Playthroughs and Best Times reference, so it must never change once a
/// build has shipped — unlike position in a list, which is what the old
/// <c>GameManager.LevelScenes</c> array used and which every save depended on.
/// </para>
/// <para>
/// The scene is a <see cref="PackedScene"/> rather than a path string. The previous
/// <c>ScenePath</c> field was never read by anything and had rotted: <c>forest_1.tres</c> pointed at
/// <c>res://scenes/level_forest_5.tscn</c>, which does not exist. An exported reference is tracked by
/// the editor and cannot drift.
/// </para>
/// </remarks>
[GlobalClass]
public partial class LevelResource : Resource
{
[Export]
/// <summary>Stable identity. Referenced by saves — do not change after shipping.</summary>
[Export]
public string Id { get; set; } = string.Empty;
/// <summary>Translation key for the player-facing name. Labels auto-translate it.</summary>
[Export]
public string LevelName { get; set; } = string.Empty;
[Export]
public string ScenePath { get; set; } = string.Empty;
}
[Export]
public PackedScene Scene { get; set; }
}