fix(levels): ScenePath must be a string, not a PackedScene

slice 7 regression. level_village_2..5.tscn each reference their own
LevelResource for the death screen label, so pointing the resource back
at the scene forms a cycle Godot cannot resolve - "Parse Error: Busy"
and the level fails to load entirely.

ScenePath was a string for exactly this reason. Reverted, and the rot it
allowed (forest_1 aimed at a nonexistent scene) is now caught by
LevelCatalogue.Validate checking every path resolves, plus a repo-level
structural check - fixed by validation rather than by typing.

project.godot: restores the C# feature flag and dialogue_manager
uses_dotnet, which `godot --headless --import` strips on every run.
This commit is contained in:
2026-07-28 19:40:02 +02:00
parent 7a69292066
commit f85c16c641
12 changed files with 74 additions and 43 deletions

View File

@@ -23,7 +23,7 @@ config/version="in-dev"
run/main_scene="uid://cl00e2ocomk3m" run/main_scene="uid://cl00e2ocomk3m"
config/use_custom_user_dir=true config/use_custom_user_dir=true
config/custom_user_dir_name="MrBrickAdventures" config/custom_user_dir_name="MrBrickAdventures"
config/features=PackedStringArray("4.6", "C#", "GL Compatibility") config/features=PackedStringArray("4.7", "C#", "GL Compatibility")
run/max_fps=180 run/max_fps=180
boot_splash/bg_color=Color(0, 0, 0, 1) boot_splash/bg_color=Color(0, 0, 0, 1)
boot_splash/show_image=false boot_splash/show_image=false

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3 uid="uid://dsq0y2iw7tfmk"] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3 uid="uid://dsq0y2iw7tfmk"]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://cq4glvuewsno5" path="res://scenes/level_forest_1.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "forest_1" Id = "forest_1"
LevelName = "LEVEL_FOREST_1_NAME" LevelName = "LEVEL_FOREST_1_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_forest_1.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -1,6 +1,6 @@
[gd_resource type="Resource" script_class="LevelCatalogue" load_steps=8 format=3] [gd_resource type="Resource" script_class="LevelCatalogue" load_steps=8 format=3]
[ext_resource type="Script" path="res://scripts/Resources/LevelCatalogue.cs" id="1_script"] [ext_resource type="Script" uid="uid://cxryfiktqs7s1" path="res://scripts/Resources/LevelCatalogue.cs" id="1_script"]
[ext_resource type="Resource" path="res://resources/levels/tutorial_1.tres" id="2_tutorial_1"] [ext_resource type="Resource" path="res://resources/levels/tutorial_1.tres" id="2_tutorial_1"]
[ext_resource type="Resource" uid="uid://cqtalsov2bkpo" path="res://resources/levels/village/village_1.tres" id="3_village_1"] [ext_resource type="Resource" uid="uid://cqtalsov2bkpo" path="res://resources/levels/village/village_1.tres" id="3_village_1"]
[ext_resource type="Resource" uid="uid://dlwkbjw1l33uq" path="res://resources/levels/village/village_2.tres" id="4_village_2"] [ext_resource type="Resource" uid="uid://dlwkbjw1l33uq" path="res://resources/levels/village/village_2.tres" id="4_village_2"]

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://dwr3avefuwnpy" path="res://scenes/level_tutorial_1.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "tutorial_1" Id = "tutorial_1"
LevelName = "LEVEL_TUTORIAL_1_NAME" LevelName = "LEVEL_TUTORIAL_1_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_tutorial_1.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3 uid="uid://cqtalsov2bkpo"] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3 uid="uid://cqtalsov2bkpo"]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://bol7g83v2accs" path="res://scenes/level_village_1.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "village_1" Id = "village_1"
LevelName = "LEVEL_1_NAME" LevelName = "LEVEL_1_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_village_1.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3 uid="uid://dlwkbjw1l33uq"] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3 uid="uid://dlwkbjw1l33uq"]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://chqb11pfoqmeb" path="res://scenes/level_village_2.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "village_2" Id = "village_2"
LevelName = "LEVEL_2_NAME" LevelName = "LEVEL_2_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_village_2.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3 uid="uid://b63u5qfp8p7pv"] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3 uid="uid://b63u5qfp8p7pv"]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://h60obxmju6mo" path="res://scenes/level_village_3.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "village_3" Id = "village_3"
LevelName = "LEVEL_3_NAME" LevelName = "LEVEL_3_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_village_3.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3 uid="uid://cxfei28yu3buh"] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3 uid="uid://cxfei28yu3buh"]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://bhad760x3vvco" path="res://scenes/level_village_4.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "village_4" Id = "village_4"
LevelName = "LEVEL_4_NAME" LevelName = "LEVEL_4_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_village_4.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3 uid="uid://qld841xv5ui0"] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3 uid="uid://qld841xv5ui0"]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://bbwef3n2gjkt8" path="res://scenes/level_village_5.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "village_5" Id = "village_5"
LevelName = "LEVEL_5_NAME" LevelName = "LEVEL_5_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_village_5.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -1,11 +1,10 @@
[gd_resource type="Resource" script_class="LevelResource" load_steps=3 format=3] [gd_resource type="Resource" script_class="LevelResource" load_steps=2 format=3]
[ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"] [ext_resource type="Script" uid="uid://c2h0pqhxiqswe" path="res://scripts/Resources/LevelResource.cs" id="1_script"]
[ext_resource type="PackedScene" uid="uid://dqekccjkwev4y" path="res://scenes/level_village_6.tscn" id="2_scene"]
[resource] [resource]
script = ExtResource("1_script") script = ExtResource("1_script")
Id = "village_6" Id = "village_6"
LevelName = "LEVEL_6_NAME" LevelName = "LEVEL_6_NAME"
Scene = ExtResource("2_scene") ScenePath = "res://scenes/level_village_6.tscn"
metadata/_custom_type_script = "uid://cp68km8bykymb" metadata/_custom_type_script = "uid://cp68km8bykymb"

View File

@@ -27,8 +27,8 @@ public partial class LevelCatalogue : Resource
/// <summary> /// <summary>
/// The ordering the domain works from. Throws if the authored data is inconsistent — a blank or /// 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 /// duplicated id cannot produce a <see cref="Campaign"/>, so a mistake fails at boot rather than
/// than corrupting a save later. /// corrupting a save later.
/// </summary> /// </summary>
public Campaign ToCampaign() public Campaign ToCampaign()
{ {
@@ -36,19 +36,48 @@ public partial class LevelCatalogue : Resource
for (var i = 0; i < Levels.Count; i++) for (var i = 0; i < Levels.Count; i++)
{ {
var level = Levels[i]; if (Levels[i] == null)
if (level == null) throw new System.InvalidOperationException($"LevelCatalogue has an empty slot at index {i}.");
throw new System.InvalidOperationException(
$"LevelCatalogue has an empty slot at index {i}.");
ids.Add(LevelId.Of(level.Id)); ids.Add(LevelId.Of(Levels[i].Id));
} }
return Campaign.Of(ids); return Campaign.Of(ids);
} }
/// <summary>
/// Checks every catalogued Level names a scene that actually exists, returning the problems found.
/// </summary>
/// <remarks>
/// This is what keeps <see cref="LevelResource.ScenePath"/> honest. The path cannot be a typed
/// <c>PackedScene</c> — several level scenes reference their own LevelResource, so a reference back
/// to the scene is a cycle Godot reports as "Parse Error: Busy" and the level fails to load. A
/// string plus this check gets the safety without the cycle.
/// </remarks>
public IReadOnlyList<string> Validate()
{
var problems = new List<string>();
foreach (var level in Levels.Where(l => l != null))
{
if (string.IsNullOrWhiteSpace(level.ScenePath))
problems.Add($"level '{level.Id}' has no scene path");
else if (!ResourceLoader.Exists(level.ScenePath))
problems.Add($"level '{level.Id}' points at '{level.ScenePath}', which does not exist");
}
return problems;
}
public LevelResource Find(LevelId id) => public LevelResource Find(LevelId id) =>
Levels.FirstOrDefault(level => level != null && level.Id == id.Value); Levels.FirstOrDefault(level => level != null && level.Id == id.Value);
public PackedScene SceneFor(LevelId id) => Find(id)?.Scene; /// <summary>Loads a Level's scene on demand. Null when the Level is not catalogued.</summary>
public PackedScene SceneFor(LevelId id)
{
var path = Find(id)?.ScenePath;
return string.IsNullOrWhiteSpace(path) ? null : ResourceLoader.Load<PackedScene>(path);
}
public string DisplayNameKeyFor(LevelId id) => Find(id)?.LevelName ?? string.Empty;
} }

View File

@@ -3,19 +3,25 @@ using Godot;
namespace Mr.BrickAdventures.scripts.Resources; namespace Mr.BrickAdventures.scripts.Resources;
/// <summary> /// <summary>
/// One authored Level: its stable id, its display name key, and the scene to load. /// One authored Level: its stable id, its display name key, and where its scene lives.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// <see cref="Id"/> is what Playthroughs and Best Times reference, so it must never change once a /// <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 /// build has shipped — unlike position in a list, which is what the old <c>GameManager.LevelScenes</c>
/// <c>GameManager.LevelScenes</c> array used and which every save depended on. /// array used and which every save depended on.
/// </para> /// </para>
/// <para> /// <para>
/// The scene is a <see cref="PackedScene"/> rather than a path string. The previous /// <see cref="ScenePath"/> is deliberately a path string and not an exported <c>PackedScene</c>.
/// <c>ScenePath</c> field was never read by anything and had rotted: <c>forest_1.tres</c> pointed at /// Several level scenes reference their own <c>LevelResource</c> (for the death screen's label), so a
/// <c>res://scenes/level_forest_5.tscn</c>, which does not exist. An exported reference is tracked by /// typed reference back to the scene forms a cycle that Godot cannot resolve — it fails with
/// the editor and cannot drift. /// "Parse Error: Busy" and the level will not load at all.
/// </para>
/// <para>
/// The trade-off is that a path can rot: this field previously pointed at
/// <c>res://scenes/level_forest_5.tscn</c>, which does not exist. That is fixed by validation rather
/// than by typing — <c>LevelCatalogue.Validate</c> checks every path resolves, so a bad one fails
/// loudly at boot instead of silently.
/// </para> /// </para>
/// </remarks> /// </remarks>
[GlobalClass] [GlobalClass]
@@ -29,6 +35,10 @@ public partial class LevelResource : Resource
[Export] [Export]
public string LevelName { get; set; } = string.Empty; public string LevelName { get; set; } = string.Empty;
[Export] /// <summary>
public PackedScene Scene { get; set; } /// <c>res://</c> or <c>uid://</c> path to the scene. Loaded on demand, never held as a typed
/// reference — see the remarks above.
/// </summary>
[Export(PropertyHint.File, "*.tscn")]
public string ScenePath { get; set; } = string.Empty;
} }