From efcc1ba2095c8cd8042316444cc88b351b6855a2 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Tue, 21 Apr 2026 19:47:25 +0200 Subject: [PATCH] Add core content definitions --- SLICE2.MD | 65 ++- SideScrollerGame.sln | 31 ++ godot/scenes/bootstrap/GameRoot.tscn | 4 +- godot/scenes/debug/ContentBrowser.tscn | 39 ++ godot/scripts/bootstrap/GameRoot.cs | 8 +- godot/scripts/content/ContentRegistry.cs | 130 ++++++ godot/scripts/content/ContentRegistry.cs.uid | 1 + godot/scripts/content/DefinitionEntry.cs | 3 + godot/scripts/content/DefinitionEntry.cs.uid | 1 + .../definitions/BehaviorEventDefinition.cs | 5 + .../BehaviorEventDefinition.cs.uid | 1 + .../content/definitions/BehaviorEventKind.cs | 12 + .../definitions/BehaviorEventKind.cs.uid | 1 + .../definitions/BehaviorTrackDefinition.cs | 5 + .../BehaviorTrackDefinition.cs.uid | 1 + .../content/definitions/BehaviorTrackMode.cs | 7 + .../definitions/BehaviorTrackMode.cs.uid | 1 + .../definitions/CameraPathDefinition.cs | 5 + .../definitions/CameraPathDefinition.cs.uid | 1 + .../definitions/CameraPathPointDefinition.cs | 3 + .../CameraPathPointDefinition.cs.uid | 1 + .../content/definitions/ClusterEscapeRule.cs | 8 + .../definitions/ClusterEscapeRule.cs.uid | 1 + .../definitions/CollectibleDefinition.cs | 5 + .../definitions/CollectibleDefinition.cs.uid | 1 + .../content/definitions/CollectibleKind.cs | 12 + .../definitions/CollectibleKind.cs.uid | 1 + .../definitions/DifficultyDefinition.cs | 3 + .../definitions/DifficultyDefinition.cs.uid | 1 + .../definitions/DifficultyModifierSet.cs | 3 + .../definitions/DifficultyModifierSet.cs.uid | 1 + .../definitions/EnemyBehaviorDefinition.cs | 5 + .../EnemyBehaviorDefinition.cs.uid | 1 + .../definitions/EnemyClusterDefinition.cs | 5 + .../definitions/EnemyClusterDefinition.cs.uid | 1 + .../definitions/EnemyTypeDefinition.cs | 5 + .../definitions/EnemyTypeDefinition.cs.uid | 1 + .../scripts/content/definitions/LayerKind.cs | 8 + .../content/definitions/LayerKind.cs.uid | 1 + .../definitions/LevelLayerDefinition.cs | 3 + .../definitions/LevelLayerDefinition.cs.uid | 1 + .../content/definitions/MissionDefinition.cs | 5 + .../definitions/MissionDefinition.cs.uid | 1 + .../SpawnScheduleEntryDefinition.cs | 3 + .../SpawnScheduleEntryDefinition.cs.uid | 1 + .../definitions/SpecialWeaponDefinition.cs | 3 + .../SpecialWeaponDefinition.cs.uid | 1 + .../content/definitions/SpecialWeaponKind.cs | 9 + .../definitions/SpecialWeaponKind.cs.uid | 1 + .../definitions/SquadronMateFormationKind.cs | 10 + .../SquadronMateFormationKind.cs.uid | 1 + .../definitions/SquadronMateTypeDefinition.cs | 3 + .../SquadronMateTypeDefinition.cs.uid | 1 + .../content/definitions/WeaponDefinition.cs | 3 + .../definitions/WeaponDefinition.cs.uid | 1 + .../scripts/content/definitions/WeaponKind.cs | 12 + .../content/definitions/WeaponKind.cs.uid | 1 + .../scripts/content/samples/SampleContent.cs | 99 +++++ .../content/samples/SampleContent.cs.uid | 1 + .../validation/ContentValidationMessage.cs | 5 + .../ContentValidationMessage.cs.uid | 1 + .../validation/ContentValidationResult.cs | 16 + .../validation/ContentValidationResult.cs.uid | 1 + .../validation/ContentValidationSeverity.cs | 8 + .../ContentValidationSeverity.cs.uid | 1 + .../content/validation/ContentValidator.cs | 374 ++++++++++++++++++ .../validation/ContentValidator.cs.uid | 1 + .../scripts/debug/ContentBrowserController.cs | 80 ++++ .../debug/ContentBrowserController.cs.uid | 1 + godot/scripts/debug/DebugBootMode.cs | 3 +- godot/scripts/debug/DebugSettings.cs | 23 +- .../ContentValidationTests.cs | 111 ++++++ .../SideScrollerGame.Content.Tests.csproj | 27 ++ .../UnitTest1.cs | 9 + 74 files changed, 1196 insertions(+), 13 deletions(-) create mode 100644 godot/scenes/debug/ContentBrowser.tscn create mode 100644 godot/scripts/content/ContentRegistry.cs create mode 100644 godot/scripts/content/ContentRegistry.cs.uid create mode 100644 godot/scripts/content/DefinitionEntry.cs create mode 100644 godot/scripts/content/DefinitionEntry.cs.uid create mode 100644 godot/scripts/content/definitions/BehaviorEventDefinition.cs create mode 100644 godot/scripts/content/definitions/BehaviorEventDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/BehaviorEventKind.cs create mode 100644 godot/scripts/content/definitions/BehaviorEventKind.cs.uid create mode 100644 godot/scripts/content/definitions/BehaviorTrackDefinition.cs create mode 100644 godot/scripts/content/definitions/BehaviorTrackDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/BehaviorTrackMode.cs create mode 100644 godot/scripts/content/definitions/BehaviorTrackMode.cs.uid create mode 100644 godot/scripts/content/definitions/CameraPathDefinition.cs create mode 100644 godot/scripts/content/definitions/CameraPathDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/CameraPathPointDefinition.cs create mode 100644 godot/scripts/content/definitions/CameraPathPointDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/ClusterEscapeRule.cs create mode 100644 godot/scripts/content/definitions/ClusterEscapeRule.cs.uid create mode 100644 godot/scripts/content/definitions/CollectibleDefinition.cs create mode 100644 godot/scripts/content/definitions/CollectibleDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/CollectibleKind.cs create mode 100644 godot/scripts/content/definitions/CollectibleKind.cs.uid create mode 100644 godot/scripts/content/definitions/DifficultyDefinition.cs create mode 100644 godot/scripts/content/definitions/DifficultyDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/DifficultyModifierSet.cs create mode 100644 godot/scripts/content/definitions/DifficultyModifierSet.cs.uid create mode 100644 godot/scripts/content/definitions/EnemyBehaviorDefinition.cs create mode 100644 godot/scripts/content/definitions/EnemyBehaviorDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/EnemyClusterDefinition.cs create mode 100644 godot/scripts/content/definitions/EnemyClusterDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/EnemyTypeDefinition.cs create mode 100644 godot/scripts/content/definitions/EnemyTypeDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/LayerKind.cs create mode 100644 godot/scripts/content/definitions/LayerKind.cs.uid create mode 100644 godot/scripts/content/definitions/LevelLayerDefinition.cs create mode 100644 godot/scripts/content/definitions/LevelLayerDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/MissionDefinition.cs create mode 100644 godot/scripts/content/definitions/MissionDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs create mode 100644 godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/SpecialWeaponDefinition.cs create mode 100644 godot/scripts/content/definitions/SpecialWeaponDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/SpecialWeaponKind.cs create mode 100644 godot/scripts/content/definitions/SpecialWeaponKind.cs.uid create mode 100644 godot/scripts/content/definitions/SquadronMateFormationKind.cs create mode 100644 godot/scripts/content/definitions/SquadronMateFormationKind.cs.uid create mode 100644 godot/scripts/content/definitions/SquadronMateTypeDefinition.cs create mode 100644 godot/scripts/content/definitions/SquadronMateTypeDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/WeaponDefinition.cs create mode 100644 godot/scripts/content/definitions/WeaponDefinition.cs.uid create mode 100644 godot/scripts/content/definitions/WeaponKind.cs create mode 100644 godot/scripts/content/definitions/WeaponKind.cs.uid create mode 100644 godot/scripts/content/samples/SampleContent.cs create mode 100644 godot/scripts/content/samples/SampleContent.cs.uid create mode 100644 godot/scripts/content/validation/ContentValidationMessage.cs create mode 100644 godot/scripts/content/validation/ContentValidationMessage.cs.uid create mode 100644 godot/scripts/content/validation/ContentValidationResult.cs create mode 100644 godot/scripts/content/validation/ContentValidationResult.cs.uid create mode 100644 godot/scripts/content/validation/ContentValidationSeverity.cs create mode 100644 godot/scripts/content/validation/ContentValidationSeverity.cs.uid create mode 100644 godot/scripts/content/validation/ContentValidator.cs create mode 100644 godot/scripts/content/validation/ContentValidator.cs.uid create mode 100644 godot/scripts/debug/ContentBrowserController.cs create mode 100644 godot/scripts/debug/ContentBrowserController.cs.uid create mode 100644 tests/SideScrollerGame.Content.Tests/ContentValidationTests.cs create mode 100644 tests/SideScrollerGame.Content.Tests/SideScrollerGame.Content.Tests.csproj create mode 100644 tests/SideScrollerGame.Content.Tests/UnitTest1.cs diff --git a/SLICE2.MD b/SLICE2.MD index 8e1ec72..aaa41ba 100644 --- a/SLICE2.MD +++ b/SLICE2.MD @@ -17,13 +17,13 @@ This slice does not implement gameplay movement, enemy spawning, weapons, or the - [x] (2026-04-21 17:04Z) Read repository rules, Windows rules, `PLANS.md`, `DESIGN.md`, `CODE.md`, `SLICE1.MD`, current Godot scripts, and `godot/project.godot`. - [x] (2026-04-21 17:04Z) Verified that Slice 1 has committed a bootable Godot shell with `Menu` and `Smoke` debug boot modes. - [x] (2026-04-21 17:04Z) Created this Slice 2 ExecPlan. -- [ ] Implement C# content definition types and validation result types. -- [ ] Implement sample content registry and intentionally broken validation fixtures. -- [ ] Add C# test project to the solution and cover registry validation behavior. -- [ ] Add Godot content browser scene, content browser boot mode, and headless validation-only exit path. -- [ ] Run formatting for touched C# files with `jb cleanupcode --build=False`. -- [ ] Validate with .NET tests, .NET build, Godot solution build, and headless content browser boot. -- [ ] Commit the completed slice. +- [x] (2026-04-21 17:32Z) Implemented C# content definition types and validation result types. +- [x] (2026-04-21 17:32Z) Implemented sample content registry and intentionally broken validation fixtures inside tests. +- [x] (2026-04-21 17:32Z) Added C# test project to the solution and covered registry validation behavior. +- [x] (2026-04-21 17:32Z) Added Godot content browser scene, content browser boot mode, and headless validation-only exit path. +- [x] (2026-04-21 17:32Z) Ran formatting for touched C# files with `jb cleanupcode --build=False`. +- [x] (2026-04-21 17:32Z) Validated with .NET tests, .NET build, Godot solution build, and headless content browser boot. +- [x] (2026-04-21 17:32Z) Commit the completed slice. ## Surprises & Discoveries @@ -36,6 +36,12 @@ This slice does not implement gameplay movement, enemy spawning, weapons, or the - Observation: There are no existing gameplay content definitions or tests in the repository. Evidence: `rg --files` shows only bootstrap, menu, debug smoke scripts and scenes under `godot/`; there is no `tests/` directory. +- Observation: Referencing the Godot C# project from the xUnit project made the Godot source generator run in the test project. + Evidence: the first `dotnet test SideScrollerGame.sln` pass printed `CS8785: Generator 'ScriptPathAttributeGenerator' failed ... Property 'GodotProjectDir' is null or empty`. Adding `GodotProjectDir` and `CompilerVisibleProperty Include="GodotProjectDir"` to the test project removed the warning. + +- Observation: `.\godot --headless --path godot --build-solutions --quit` generated `.cs.uid` files for every new C# script, including plain content definitions. + Evidence: after the Godot build, `rg --files godot\scripts godot\scenes` listed `.uid` files beside all new content and validation C# files. These are kept because Godot generated them for the project scan. + ## Decision Log - Decision: Implement Slice 2 definitions as plain C# records and small enums inside the existing Godot C# project first, not as Godot `Resource` assets. @@ -54,9 +60,52 @@ This slice does not implement gameplay movement, enemy spawning, weapons, or the Rationale: The jam topic is unknown. Stable ids such as `mission.test`, `enemy.serial`, and `weapon.primary.basic` prove system wiring without committing to a visual or story theme. Date/Author: 2026-04-21 / Codex. +- Decision: Keep content definitions inside the Godot C# project for Slice 2 and configure the test project so the Godot source generator has `GodotProjectDir`. + Rationale: The Godot project reference worked after the generator property was made visible to the compiler. This preserves the planned `godot/scripts/content/` layout and avoids introducing an extra class library before the content model needs to be shared outside Godot. + Date/Author: 2026-04-21 / Codex. + ## Outcomes & Retrospective -Not started. When this slice is completed, update this section with the exact files created, commands run, validation output, and any remaining risks. +Implemented the core content definition layer under `godot/scripts/content/`, including definitions, registry lookup, validation messages, validation rules, and theme-neutral sample content. Added `tests/SideScrollerGame.Content.Tests/` with xUnit coverage for valid sample content, registry lookup, duplicate ids, missing mission clusters, missing cluster enemies, empty behavior tracks, and invalid difficulty multipliers. Added `ContentBrowser` as a debug boot mode and created `godot/scenes/debug/ContentBrowser.tscn` for in-engine content inspection and headless validation. + +Validation completed: + + dotnet test SideScrollerGame.sln + Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8 + + dotnet build SideScrollerGame.sln + Build succeeded. + 0 Warning(s) + 0 Error(s) + + .\godot --headless --path godot --build-solutions --quit + Exited successfully. + + .\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only + Debug boot: ContentBrowser + Seed: 1 + Loaded content definitions: + behavior.enemy.parallel + behavior.enemy.serial + camera.test.path + cluster.opening + collectible.points.small + collectible.squadron.orbit + difficulty.easy + difficulty.hard + difficulty.normal + enemy.parallel + enemy.serial + layer.background.stars + layer.foreground.clouds + mission.test + squadron.orbit + weapon.primary.basic + weapon.secondary.vertical + weapon.special.bomb + Content validation succeeded + +Remaining risk: the content browser visual layout was validated through headless boot and compilation, not manually inspected in the editor window. ## Context and Orientation diff --git a/SideScrollerGame.sln b/SideScrollerGame.sln index 95c8099..86ae3fe 100644 --- a/SideScrollerGame.sln +++ b/SideScrollerGame.sln @@ -3,6 +3,10 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 2012 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Godot", "godot/SideScrollerGame.Godot.csproj", "{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Content.Tests", "tests\SideScrollerGame.Content.Tests\SideScrollerGame.Content.Tests.csproj", "{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -43,8 +47,35 @@ Global {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x64.Build.0 = Release|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x86.ActiveCfg = Release|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x86.Build.0 = Release|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x64.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x64.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x86.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x64.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x64.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x86.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x86.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x64.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x64.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x86.ActiveCfg = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x86.Build.0 = Debug|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|Any CPU.Build.0 = Release|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x64.ActiveCfg = Release|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x64.Build.0 = Release|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x86.ActiveCfg = Release|Any CPU + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection EndGlobal diff --git a/godot/scenes/bootstrap/GameRoot.tscn b/godot/scenes/bootstrap/GameRoot.tscn index d630113..74bc46e 100644 --- a/godot/scenes/bootstrap/GameRoot.tscn +++ b/godot/scenes/bootstrap/GameRoot.tscn @@ -1,14 +1,16 @@ -[gd_scene load_steps=5 format=3 uid="uid://b1fxc23gkbqre"] +[gd_scene load_steps=6 format=3 uid="uid://b1fxc23gkbqre"] [ext_resource type="Script" path="res://scripts/bootstrap/GameRoot.cs" id="1_game_root"] [ext_resource type="Script" path="res://scripts/debug/DebugOverlay.cs" id="2_debug_overlay"] [ext_resource type="PackedScene" path="res://scenes/menu/MenuPlaceholder.tscn" id="3_menu"] [ext_resource type="PackedScene" path="res://scenes/debug/SmokeScene.tscn" id="4_smoke"] +[ext_resource type="PackedScene" path="res://scenes/debug/ContentBrowser.tscn" id="5_content_browser"] [node name="GameRoot" type="Node"] script = ExtResource("1_game_root") MenuScene = ExtResource("3_menu") SmokeScene = ExtResource("4_smoke") +ContentBrowserScene = ExtResource("5_content_browser") [node name="DebugOverlay" type="CanvasLayer" parent="."] script = ExtResource("2_debug_overlay") diff --git a/godot/scenes/debug/ContentBrowser.tscn b/godot/scenes/debug/ContentBrowser.tscn new file mode 100644 index 0000000..573d6d3 --- /dev/null +++ b/godot/scenes/debug/ContentBrowser.tscn @@ -0,0 +1,39 @@ +[gd_scene load_steps=2 format=3 uid="uid://cahv34nq56781"] + +[ext_resource type="Script" path="res://scripts/debug/ContentBrowserController.cs" id="1_content_browser"] + +[node name="ContentBrowser" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_content_browser") + +[node name="Title" type="Label" parent="."] +layout_mode = 1 +anchors_preset = 10 +anchor_right = 1.0 +offset_left = 16.0 +offset_top = 16.0 +offset_right = -16.0 +offset_bottom = 48.0 +grow_horizontal = 2 +text = "Content Browser" + +[node name="Scroll" type="ScrollContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 16.0 +offset_top = 56.0 +offset_right = -16.0 +offset_bottom = -16.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="Content" type="Label" parent="Scroll"] +layout_mode = 2 +text = "Loading..." diff --git a/godot/scripts/bootstrap/GameRoot.cs b/godot/scripts/bootstrap/GameRoot.cs index a9ba209..a882853 100644 --- a/godot/scripts/bootstrap/GameRoot.cs +++ b/godot/scripts/bootstrap/GameRoot.cs @@ -22,8 +22,9 @@ public partial class GameRoot : Node { PackedScene? scene = bootMode switch { - DebugBootMode.Smoke => SmokeScene, - _ => MenuScene + DebugBootMode.Smoke => SmokeScene, + DebugBootMode.ContentBrowser => ContentBrowserScene, + _ => MenuScene }; string loadedSceneId = bootMode.ToString(); @@ -54,6 +55,9 @@ public partial class GameRoot : Node [Export] public PackedScene? SmokeScene { get; set; } + [Export] + public PackedScene? ContentBrowserScene { get; set; } + private Node? m_LoadedScene; private DebugSettings? m_Settings; } \ No newline at end of file diff --git a/godot/scripts/content/ContentRegistry.cs b/godot/scripts/content/ContentRegistry.cs new file mode 100644 index 0000000..ec6789a --- /dev/null +++ b/godot/scripts/content/ContentRegistry.cs @@ -0,0 +1,130 @@ +#nullable enable + +using System.Collections.Generic; +using System.Linq; +using SideScrollerGame.Content.Definitions; + +namespace SideScrollerGame.Content; + +public sealed class ContentRegistry +{ + public ContentRegistry(IEnumerable missions, IEnumerable difficulties, IEnumerable cameraPaths, IEnumerable levelLayers, IEnumerable enemyTypes, IEnumerable enemyBehaviors, IEnumerable enemyClusters, IEnumerable collectibles, IEnumerable weapons, IEnumerable specialWeapons, IEnumerable squadronMateTypes) + { + MissionDefinitions = missions.ToList(); + DifficultyDefinitions = difficulties.ToList(); + CameraPathDefinitions = cameraPaths.ToList(); + LevelLayerDefinitions = levelLayers.ToList(); + EnemyTypeDefinitions = enemyTypes.ToList(); + EnemyBehaviorDefinitions = enemyBehaviors.ToList(); + EnemyClusterDefinitions = enemyClusters.ToList(); + CollectibleDefinitions = collectibles.ToList(); + WeaponDefinitions = weapons.ToList(); + SpecialWeaponDefinitions = specialWeapons.ToList(); + SquadronMateTypeDefinitions = squadronMateTypes.ToList(); + + Missions = ToDictionary(MissionDefinitions); + Difficulties = ToDictionary(DifficultyDefinitions); + CameraPaths = ToDictionary(CameraPathDefinitions); + LevelLayers = ToDictionary(LevelLayerDefinitions); + EnemyTypes = ToDictionary(EnemyTypeDefinitions); + EnemyBehaviors = ToDictionary(EnemyBehaviorDefinitions); + EnemyClusters = ToDictionary(EnemyClusterDefinitions); + Collectibles = ToDictionary(CollectibleDefinitions); + Weapons = ToDictionary(WeaponDefinitions); + SpecialWeapons = ToDictionary(SpecialWeaponDefinitions); + SquadronMateTypes = ToDictionary(SquadronMateTypeDefinitions); + } + + public IEnumerable AllDefinitionIds() + { + return AllDefinitions().Select(definition => definition.Id).OrderBy(id => id); + } + + public IReadOnlyList AllDefinitions() + { + return MissionDefinitions.Select(definition => new DefinitionEntry("Mission", definition.Id)).Concat(DifficultyDefinitions.Select(definition => new DefinitionEntry("Difficulty", definition.Id))).Concat(CameraPathDefinitions.Select(definition => new DefinitionEntry("CameraPath", definition.Id))).Concat(LevelLayerDefinitions.Select(definition => new DefinitionEntry("LevelLayer", definition.Id))).Concat(EnemyTypeDefinitions.Select(definition => new DefinitionEntry("EnemyType", definition.Id))).Concat(EnemyBehaviorDefinitions.Select(definition => new DefinitionEntry("EnemyBehavior", definition.Id))).Concat(EnemyClusterDefinitions.Select(definition => new DefinitionEntry("EnemyCluster", definition.Id))).Concat(CollectibleDefinitions.Select(definition => new DefinitionEntry("Collectible", definition.Id))).Concat(WeaponDefinitions.Select(definition => new DefinitionEntry("Weapon", definition.Id))).Concat(SpecialWeaponDefinitions.Select(definition => new DefinitionEntry("SpecialWeapon", definition.Id))).Concat(SquadronMateTypeDefinitions.Select(definition => new DefinitionEntry("SquadronMateType", definition.Id))).OrderBy(definition => definition.Id).ToList(); + } + + public bool TryGetMission(string id, out MissionDefinition? definition) + { + return Missions.TryGetValue(id, out definition); + } + + public bool TryGetDifficulty(string id, out DifficultyDefinition? definition) + { + return Difficulties.TryGetValue(id, out definition); + } + + public bool TryGetEnemyType(string id, out EnemyTypeDefinition? definition) + { + return EnemyTypes.TryGetValue(id, out definition); + } + + public IReadOnlyList MissionDefinitions { get; } + + public IReadOnlyList DifficultyDefinitions { get; } + + public IReadOnlyList CameraPathDefinitions { get; } + + public IReadOnlyList LevelLayerDefinitions { get; } + + public IReadOnlyList EnemyTypeDefinitions { get; } + + public IReadOnlyList EnemyBehaviorDefinitions { get; } + + public IReadOnlyList EnemyClusterDefinitions { get; } + + public IReadOnlyList CollectibleDefinitions { get; } + + public IReadOnlyList WeaponDefinitions { get; } + + public IReadOnlyList SpecialWeaponDefinitions { get; } + + public IReadOnlyList SquadronMateTypeDefinitions { get; } + + public IReadOnlyDictionary Missions { get; } + + public IReadOnlyDictionary Difficulties { get; } + + public IReadOnlyDictionary CameraPaths { get; } + + public IReadOnlyDictionary LevelLayers { get; } + + public IReadOnlyDictionary EnemyTypes { get; } + + public IReadOnlyDictionary EnemyBehaviors { get; } + + public IReadOnlyDictionary EnemyClusters { get; } + + public IReadOnlyDictionary Collectibles { get; } + + public IReadOnlyDictionary Weapons { get; } + + public IReadOnlyDictionary SpecialWeapons { get; } + + public IReadOnlyDictionary SquadronMateTypes { get; } + + private static IReadOnlyDictionary ToDictionary(IEnumerable definitions) where TDefinition : class + { + return definitions.GroupBy(GetId).Select(group => group.First()).ToDictionary(GetId); + } + + private static string GetId(TDefinition definition) + { + return definition switch + { + MissionDefinition mission => mission.Id, + DifficultyDefinition difficulty => difficulty.Id, + CameraPathDefinition cameraPath => cameraPath.Id, + LevelLayerDefinition levelLayer => levelLayer.Id, + EnemyTypeDefinition enemyType => enemyType.Id, + EnemyBehaviorDefinition enemyBehavior => enemyBehavior.Id, + EnemyClusterDefinition enemyCluster => enemyCluster.Id, + CollectibleDefinition collectible => collectible.Id, + WeaponDefinition weapon => weapon.Id, + SpecialWeaponDefinition specialWeapon => specialWeapon.Id, + SquadronMateTypeDefinition squadronMateDefinition => squadronMateDefinition.Id, + _ => string.Empty + }; + } +} \ No newline at end of file diff --git a/godot/scripts/content/ContentRegistry.cs.uid b/godot/scripts/content/ContentRegistry.cs.uid new file mode 100644 index 0000000..3b84375 --- /dev/null +++ b/godot/scripts/content/ContentRegistry.cs.uid @@ -0,0 +1 @@ +uid://ced4h8gt2he8a diff --git a/godot/scripts/content/DefinitionEntry.cs b/godot/scripts/content/DefinitionEntry.cs new file mode 100644 index 0000000..19aaa6a --- /dev/null +++ b/godot/scripts/content/DefinitionEntry.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content; + +public sealed record DefinitionEntry(string Kind, string Id); \ No newline at end of file diff --git a/godot/scripts/content/DefinitionEntry.cs.uid b/godot/scripts/content/DefinitionEntry.cs.uid new file mode 100644 index 0000000..565ddc4 --- /dev/null +++ b/godot/scripts/content/DefinitionEntry.cs.uid @@ -0,0 +1 @@ +uid://dlbg73ke5c56n diff --git a/godot/scripts/content/definitions/BehaviorEventDefinition.cs b/godot/scripts/content/definitions/BehaviorEventDefinition.cs new file mode 100644 index 0000000..b7964ca --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorEventDefinition.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace SideScrollerGame.Content.Definitions; + +public sealed record BehaviorEventDefinition(BehaviorEventKind Kind, double StartSeconds, double DurationSeconds, string? ReferenceId = null); \ No newline at end of file diff --git a/godot/scripts/content/definitions/BehaviorEventDefinition.cs.uid b/godot/scripts/content/definitions/BehaviorEventDefinition.cs.uid new file mode 100644 index 0000000..3417ea9 --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorEventDefinition.cs.uid @@ -0,0 +1 @@ +uid://bfhvtjvxbesyr diff --git a/godot/scripts/content/definitions/BehaviorEventKind.cs b/godot/scripts/content/definitions/BehaviorEventKind.cs new file mode 100644 index 0000000..34c1c23 --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorEventKind.cs @@ -0,0 +1,12 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum BehaviorEventKind +{ + Wait, + MovePath, + RotatePath, + FireProjectile, + ChangeSpeed, + SpawnChild, + TriggerEffect +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/BehaviorEventKind.cs.uid b/godot/scripts/content/definitions/BehaviorEventKind.cs.uid new file mode 100644 index 0000000..0b4449d --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorEventKind.cs.uid @@ -0,0 +1 @@ +uid://b3k6pfrvkik72 diff --git a/godot/scripts/content/definitions/BehaviorTrackDefinition.cs b/godot/scripts/content/definitions/BehaviorTrackDefinition.cs new file mode 100644 index 0000000..97be25e --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorTrackDefinition.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace SideScrollerGame.Content.Definitions; + +public sealed record BehaviorTrackDefinition(string Id, BehaviorTrackMode Mode, IReadOnlyList Events); \ No newline at end of file diff --git a/godot/scripts/content/definitions/BehaviorTrackDefinition.cs.uid b/godot/scripts/content/definitions/BehaviorTrackDefinition.cs.uid new file mode 100644 index 0000000..e02f283 --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorTrackDefinition.cs.uid @@ -0,0 +1 @@ +uid://b8sossiey58sy diff --git a/godot/scripts/content/definitions/BehaviorTrackMode.cs b/godot/scripts/content/definitions/BehaviorTrackMode.cs new file mode 100644 index 0000000..6f372d6 --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorTrackMode.cs @@ -0,0 +1,7 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum BehaviorTrackMode +{ + Serial, + Parallel +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/BehaviorTrackMode.cs.uid b/godot/scripts/content/definitions/BehaviorTrackMode.cs.uid new file mode 100644 index 0000000..e49d4d9 --- /dev/null +++ b/godot/scripts/content/definitions/BehaviorTrackMode.cs.uid @@ -0,0 +1 @@ +uid://cj7orw08m62rq diff --git a/godot/scripts/content/definitions/CameraPathDefinition.cs b/godot/scripts/content/definitions/CameraPathDefinition.cs new file mode 100644 index 0000000..ba9366c --- /dev/null +++ b/godot/scripts/content/definitions/CameraPathDefinition.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace SideScrollerGame.Content.Definitions; + +public sealed record CameraPathDefinition(string Id, string DisplayName, IReadOnlyList Points, double DefaultSpeed); \ No newline at end of file diff --git a/godot/scripts/content/definitions/CameraPathDefinition.cs.uid b/godot/scripts/content/definitions/CameraPathDefinition.cs.uid new file mode 100644 index 0000000..9e622c4 --- /dev/null +++ b/godot/scripts/content/definitions/CameraPathDefinition.cs.uid @@ -0,0 +1 @@ +uid://1sohi8c0b4p4 diff --git a/godot/scripts/content/definitions/CameraPathPointDefinition.cs b/godot/scripts/content/definitions/CameraPathPointDefinition.cs new file mode 100644 index 0000000..f393794 --- /dev/null +++ b/godot/scripts/content/definitions/CameraPathPointDefinition.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record CameraPathPointDefinition(double TimeSeconds, double X, double Y, double Speed); \ No newline at end of file diff --git a/godot/scripts/content/definitions/CameraPathPointDefinition.cs.uid b/godot/scripts/content/definitions/CameraPathPointDefinition.cs.uid new file mode 100644 index 0000000..38aa912 --- /dev/null +++ b/godot/scripts/content/definitions/CameraPathPointDefinition.cs.uid @@ -0,0 +1 @@ +uid://n5yggkiyqu3u diff --git a/godot/scripts/content/definitions/ClusterEscapeRule.cs b/godot/scripts/content/definitions/ClusterEscapeRule.cs new file mode 100644 index 0000000..7e1f536 --- /dev/null +++ b/godot/scripts/content/definitions/ClusterEscapeRule.cs @@ -0,0 +1,8 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum ClusterEscapeRule +{ + PreventReward, + AllowReward, + RespawnUntilDestroyed +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/ClusterEscapeRule.cs.uid b/godot/scripts/content/definitions/ClusterEscapeRule.cs.uid new file mode 100644 index 0000000..1d4cb54 --- /dev/null +++ b/godot/scripts/content/definitions/ClusterEscapeRule.cs.uid @@ -0,0 +1 @@ +uid://bo12ujllh0vko diff --git a/godot/scripts/content/definitions/CollectibleDefinition.cs b/godot/scripts/content/definitions/CollectibleDefinition.cs new file mode 100644 index 0000000..a23b5b0 --- /dev/null +++ b/godot/scripts/content/definitions/CollectibleDefinition.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace SideScrollerGame.Content.Definitions; + +public sealed record CollectibleDefinition(string Id, string DisplayName, CollectibleKind Kind, int Value, string? ReferencedContentId = null); \ No newline at end of file diff --git a/godot/scripts/content/definitions/CollectibleDefinition.cs.uid b/godot/scripts/content/definitions/CollectibleDefinition.cs.uid new file mode 100644 index 0000000..94b30cb --- /dev/null +++ b/godot/scripts/content/definitions/CollectibleDefinition.cs.uid @@ -0,0 +1 @@ +uid://dojpxw2gdr0yo diff --git a/godot/scripts/content/definitions/CollectibleKind.cs b/godot/scripts/content/definitions/CollectibleKind.cs new file mode 100644 index 0000000..84d4746 --- /dev/null +++ b/godot/scripts/content/definitions/CollectibleKind.cs @@ -0,0 +1,12 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum CollectibleKind +{ + Points, + PrimaryWeapon, + SecondaryWeapon, + ClearScreen, + ShieldCharge, + SpecialAmmo, + SquadronMate +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/CollectibleKind.cs.uid b/godot/scripts/content/definitions/CollectibleKind.cs.uid new file mode 100644 index 0000000..ccc164a --- /dev/null +++ b/godot/scripts/content/definitions/CollectibleKind.cs.uid @@ -0,0 +1 @@ +uid://bbuwcsopsg4on diff --git a/godot/scripts/content/definitions/DifficultyDefinition.cs b/godot/scripts/content/definitions/DifficultyDefinition.cs new file mode 100644 index 0000000..f0fe795 --- /dev/null +++ b/godot/scripts/content/definitions/DifficultyDefinition.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record DifficultyDefinition(string Id, string DisplayName, DifficultyModifierSet Modifiers, int HeroStartingShieldCharges, int HeroRetryCount); \ No newline at end of file diff --git a/godot/scripts/content/definitions/DifficultyDefinition.cs.uid b/godot/scripts/content/definitions/DifficultyDefinition.cs.uid new file mode 100644 index 0000000..3e3d6f0 --- /dev/null +++ b/godot/scripts/content/definitions/DifficultyDefinition.cs.uid @@ -0,0 +1 @@ +uid://8lf15cyl337v diff --git a/godot/scripts/content/definitions/DifficultyModifierSet.cs b/godot/scripts/content/definitions/DifficultyModifierSet.cs new file mode 100644 index 0000000..4164a45 --- /dev/null +++ b/godot/scripts/content/definitions/DifficultyModifierSet.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record DifficultyModifierSet(double EnemyHealthMultiplier, double EnemyProjectileSpeedMultiplier, double EnemyFireCadenceMultiplier, double EnemySpawnDensityMultiplier, double ClusterSpawnIntervalMultiplier, double BossHealthMultiplier, double BossPhaseTimingMultiplier, double CollectibleDropRateMultiplier, double SpecialWeaponInitialAmmoMultiplier, double ScoreMultiplier); \ No newline at end of file diff --git a/godot/scripts/content/definitions/DifficultyModifierSet.cs.uid b/godot/scripts/content/definitions/DifficultyModifierSet.cs.uid new file mode 100644 index 0000000..5c7e998 --- /dev/null +++ b/godot/scripts/content/definitions/DifficultyModifierSet.cs.uid @@ -0,0 +1 @@ +uid://c3wiaioy3cj2l diff --git a/godot/scripts/content/definitions/EnemyBehaviorDefinition.cs b/godot/scripts/content/definitions/EnemyBehaviorDefinition.cs new file mode 100644 index 0000000..2ce9d6f --- /dev/null +++ b/godot/scripts/content/definitions/EnemyBehaviorDefinition.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace SideScrollerGame.Content.Definitions; + +public sealed record EnemyBehaviorDefinition(string Id, string DisplayName, IReadOnlyList Tracks); \ No newline at end of file diff --git a/godot/scripts/content/definitions/EnemyBehaviorDefinition.cs.uid b/godot/scripts/content/definitions/EnemyBehaviorDefinition.cs.uid new file mode 100644 index 0000000..977a895 --- /dev/null +++ b/godot/scripts/content/definitions/EnemyBehaviorDefinition.cs.uid @@ -0,0 +1 @@ +uid://b7qggd0xsyl8v diff --git a/godot/scripts/content/definitions/EnemyClusterDefinition.cs b/godot/scripts/content/definitions/EnemyClusterDefinition.cs new file mode 100644 index 0000000..51ecb2f --- /dev/null +++ b/godot/scripts/content/definitions/EnemyClusterDefinition.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace SideScrollerGame.Content.Definitions; + +public sealed record EnemyClusterDefinition(string Id, string DisplayName, IReadOnlyList Spawns, int CompletionRewardPoints, ClusterEscapeRule EscapeRule); \ No newline at end of file diff --git a/godot/scripts/content/definitions/EnemyClusterDefinition.cs.uid b/godot/scripts/content/definitions/EnemyClusterDefinition.cs.uid new file mode 100644 index 0000000..4df83f3 --- /dev/null +++ b/godot/scripts/content/definitions/EnemyClusterDefinition.cs.uid @@ -0,0 +1 @@ +uid://c5lqrpv5gc753 diff --git a/godot/scripts/content/definitions/EnemyTypeDefinition.cs b/godot/scripts/content/definitions/EnemyTypeDefinition.cs new file mode 100644 index 0000000..d873108 --- /dev/null +++ b/godot/scripts/content/definitions/EnemyTypeDefinition.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace SideScrollerGame.Content.Definitions; + +public sealed record EnemyTypeDefinition(string Id, string DisplayName, int Health, int ScoreValue, IReadOnlyList BehaviorIds, IReadOnlyList DropCollectibleIds); \ No newline at end of file diff --git a/godot/scripts/content/definitions/EnemyTypeDefinition.cs.uid b/godot/scripts/content/definitions/EnemyTypeDefinition.cs.uid new file mode 100644 index 0000000..97b915e --- /dev/null +++ b/godot/scripts/content/definitions/EnemyTypeDefinition.cs.uid @@ -0,0 +1 @@ +uid://cgsajqr5fvw5g diff --git a/godot/scripts/content/definitions/LayerKind.cs b/godot/scripts/content/definitions/LayerKind.cs new file mode 100644 index 0000000..afa768e --- /dev/null +++ b/godot/scripts/content/definitions/LayerKind.cs @@ -0,0 +1,8 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum LayerKind +{ + Background, + Interactive, + Foreground +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/LayerKind.cs.uid b/godot/scripts/content/definitions/LayerKind.cs.uid new file mode 100644 index 0000000..f1af205 --- /dev/null +++ b/godot/scripts/content/definitions/LayerKind.cs.uid @@ -0,0 +1 @@ +uid://dp41dwk68qlin diff --git a/godot/scripts/content/definitions/LevelLayerDefinition.cs b/godot/scripts/content/definitions/LevelLayerDefinition.cs new file mode 100644 index 0000000..cde9a15 --- /dev/null +++ b/godot/scripts/content/definitions/LevelLayerDefinition.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record LevelLayerDefinition(string Id, string DisplayName, LayerKind Kind, double ScrollFactor, bool Repeats); \ No newline at end of file diff --git a/godot/scripts/content/definitions/LevelLayerDefinition.cs.uid b/godot/scripts/content/definitions/LevelLayerDefinition.cs.uid new file mode 100644 index 0000000..182330a --- /dev/null +++ b/godot/scripts/content/definitions/LevelLayerDefinition.cs.uid @@ -0,0 +1 @@ +uid://dfikxuceq8ros diff --git a/godot/scripts/content/definitions/MissionDefinition.cs b/godot/scripts/content/definitions/MissionDefinition.cs new file mode 100644 index 0000000..0b786f0 --- /dev/null +++ b/godot/scripts/content/definitions/MissionDefinition.cs @@ -0,0 +1,5 @@ +using System.Collections.Generic; + +namespace SideScrollerGame.Content.Definitions; + +public sealed record MissionDefinition(string Id, string DisplayName, string DefaultDifficultyId, string CameraPathId, IReadOnlyList BackgroundLayerIds, IReadOnlyList ForegroundLayerIds, IReadOnlyList ClusterIds, IReadOnlyList CollectibleIds, IReadOnlyList SpecialWeaponIds, IReadOnlyList TimelineMarkers); \ No newline at end of file diff --git a/godot/scripts/content/definitions/MissionDefinition.cs.uid b/godot/scripts/content/definitions/MissionDefinition.cs.uid new file mode 100644 index 0000000..bc37bc5 --- /dev/null +++ b/godot/scripts/content/definitions/MissionDefinition.cs.uid @@ -0,0 +1 @@ +uid://d1gwpoe4obx5t diff --git a/godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs b/godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs new file mode 100644 index 0000000..39806d3 --- /dev/null +++ b/godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record SpawnScheduleEntryDefinition(string EnemyTypeId, double SpawnTimeSeconds, double AnchorX, double AnchorY); \ No newline at end of file diff --git a/godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs.uid b/godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs.uid new file mode 100644 index 0000000..9a75724 --- /dev/null +++ b/godot/scripts/content/definitions/SpawnScheduleEntryDefinition.cs.uid @@ -0,0 +1 @@ +uid://crrtalqhddqc3 diff --git a/godot/scripts/content/definitions/SpecialWeaponDefinition.cs b/godot/scripts/content/definitions/SpecialWeaponDefinition.cs new file mode 100644 index 0000000..d932ceb --- /dev/null +++ b/godot/scripts/content/definitions/SpecialWeaponDefinition.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record SpecialWeaponDefinition(string Id, string DisplayName, SpecialWeaponKind Kind, int InitialAmmo, int AmmoPickupAmount, int Damage); \ No newline at end of file diff --git a/godot/scripts/content/definitions/SpecialWeaponDefinition.cs.uid b/godot/scripts/content/definitions/SpecialWeaponDefinition.cs.uid new file mode 100644 index 0000000..19317c0 --- /dev/null +++ b/godot/scripts/content/definitions/SpecialWeaponDefinition.cs.uid @@ -0,0 +1 @@ +uid://b7rncbmm228x6 diff --git a/godot/scripts/content/definitions/SpecialWeaponKind.cs b/godot/scripts/content/definitions/SpecialWeaponKind.cs new file mode 100644 index 0000000..decff96 --- /dev/null +++ b/godot/scripts/content/definitions/SpecialWeaponKind.cs @@ -0,0 +1,9 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum SpecialWeaponKind +{ + Bomb, + Crawler, + Napalm, + BlackHole +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/SpecialWeaponKind.cs.uid b/godot/scripts/content/definitions/SpecialWeaponKind.cs.uid new file mode 100644 index 0000000..81351ad --- /dev/null +++ b/godot/scripts/content/definitions/SpecialWeaponKind.cs.uid @@ -0,0 +1 @@ +uid://cr8u8s2r2vlj5 diff --git a/godot/scripts/content/definitions/SquadronMateFormationKind.cs b/godot/scripts/content/definitions/SquadronMateFormationKind.cs new file mode 100644 index 0000000..957f6d0 --- /dev/null +++ b/godot/scripts/content/definitions/SquadronMateFormationKind.cs @@ -0,0 +1,10 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum SquadronMateFormationKind +{ + Hug, + Orbit, + LineFormation, + VFormation, + Follow +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/SquadronMateFormationKind.cs.uid b/godot/scripts/content/definitions/SquadronMateFormationKind.cs.uid new file mode 100644 index 0000000..6b84661 --- /dev/null +++ b/godot/scripts/content/definitions/SquadronMateFormationKind.cs.uid @@ -0,0 +1 @@ +uid://trmmvv4tfq0l diff --git a/godot/scripts/content/definitions/SquadronMateTypeDefinition.cs b/godot/scripts/content/definitions/SquadronMateTypeDefinition.cs new file mode 100644 index 0000000..d0eced5 --- /dev/null +++ b/godot/scripts/content/definitions/SquadronMateTypeDefinition.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record SquadronMateTypeDefinition(string Id, string DisplayName, SquadronMateFormationKind Formation, double Spacing, double MovementSmoothing); \ No newline at end of file diff --git a/godot/scripts/content/definitions/SquadronMateTypeDefinition.cs.uid b/godot/scripts/content/definitions/SquadronMateTypeDefinition.cs.uid new file mode 100644 index 0000000..cff8d04 --- /dev/null +++ b/godot/scripts/content/definitions/SquadronMateTypeDefinition.cs.uid @@ -0,0 +1 @@ +uid://b0n4orx2ffcu2 diff --git a/godot/scripts/content/definitions/WeaponDefinition.cs b/godot/scripts/content/definitions/WeaponDefinition.cs new file mode 100644 index 0000000..f85debc --- /dev/null +++ b/godot/scripts/content/definitions/WeaponDefinition.cs @@ -0,0 +1,3 @@ +namespace SideScrollerGame.Content.Definitions; + +public sealed record WeaponDefinition(string Id, string DisplayName, WeaponKind Kind, int Damage, double FireCadenceSeconds, double ProjectileSpeed, int ProjectileCount, bool ConsumesEnemyProjectiles); \ No newline at end of file diff --git a/godot/scripts/content/definitions/WeaponDefinition.cs.uid b/godot/scripts/content/definitions/WeaponDefinition.cs.uid new file mode 100644 index 0000000..ad5f013 --- /dev/null +++ b/godot/scripts/content/definitions/WeaponDefinition.cs.uid @@ -0,0 +1 @@ +uid://br35s1xq4dspu diff --git a/godot/scripts/content/definitions/WeaponKind.cs b/godot/scripts/content/definitions/WeaponKind.cs new file mode 100644 index 0000000..d877fdb --- /dev/null +++ b/godot/scripts/content/definitions/WeaponKind.cs @@ -0,0 +1,12 @@ +namespace SideScrollerGame.Content.Definitions; + +public enum WeaponKind +{ + PrimaryBallistic, + PrimarySeeking, + PrimaryLaser, + PrimaryGrenadeCluster, + SecondaryVertical, + SecondaryDiagonal, + SecondaryBackward +} \ No newline at end of file diff --git a/godot/scripts/content/definitions/WeaponKind.cs.uid b/godot/scripts/content/definitions/WeaponKind.cs.uid new file mode 100644 index 0000000..1f69c22 --- /dev/null +++ b/godot/scripts/content/definitions/WeaponKind.cs.uid @@ -0,0 +1 @@ +uid://bbc7rfx8wc610 diff --git a/godot/scripts/content/samples/SampleContent.cs b/godot/scripts/content/samples/SampleContent.cs new file mode 100644 index 0000000..fbbcbeb --- /dev/null +++ b/godot/scripts/content/samples/SampleContent.cs @@ -0,0 +1,99 @@ +using System.Collections.Generic; +using SideScrollerGame.Content.Definitions; + +namespace SideScrollerGame.Content.Samples; + +public static class SampleContent +{ + public static ContentRegistry CreateRegistry() + { + IReadOnlyList difficulties = + [ + CreateDifficulty("difficulty.easy", "Easy", 0.8, 4, 4, 0.75), + CreateDifficulty("difficulty.normal", "Normal", 1.0, 3, 3, 1.0), + CreateDifficulty("difficulty.hard", "Hard", 1.35, 2, 2, 1.5) + ]; + + IReadOnlyList cameraPaths = + [ + new("camera.test.path", "Test Camera Path", [ + new CameraPathPointDefinition(0.0, 0.0, 0.0, 120.0), + new CameraPathPointDefinition(10.0, 1200.0, 0.0, 180.0), + new CameraPathPointDefinition(20.0, 2400.0, 96.0, 90.0) + ], 120.0) + ]; + + IReadOnlyList levelLayers = + [ + new("layer.background.stars", "Repeating Background", LayerKind.Background, 0.35, true), + new("layer.foreground.clouds", "Repeating Foreground", LayerKind.Foreground, 1.4, true) + ]; + + IReadOnlyList enemyBehaviors = + [ + new("behavior.enemy.serial", "Serial Movement", [ + new BehaviorTrackDefinition("track.main", BehaviorTrackMode.Serial, [ + new BehaviorEventDefinition(BehaviorEventKind.MovePath, 0.0, 2.0), + new BehaviorEventDefinition(BehaviorEventKind.Wait, 2.0, 0.5), + new BehaviorEventDefinition(BehaviorEventKind.FireProjectile, 2.5, 0.1, "weapon.enemy.placeholder") + ]) + ]), + new("behavior.enemy.parallel", "Parallel Movement And Fire", [ + new BehaviorTrackDefinition("track.move", BehaviorTrackMode.Parallel, [ + new BehaviorEventDefinition(BehaviorEventKind.MovePath, 0.0, 5.0) + ]), + new BehaviorTrackDefinition("track.fire", BehaviorTrackMode.Parallel, [ + new BehaviorEventDefinition(BehaviorEventKind.FireProjectile, 0.5, 4.0, "weapon.enemy.placeholder") + ]) + ]) + ]; + + IReadOnlyList collectibles = + [ + new("collectible.points.small", "Small Points", CollectibleKind.Points, 100), + new("collectible.squadron.orbit", "Orbit Squadron Mate", CollectibleKind.SquadronMate, 1, "squadron.orbit") + ]; + + IReadOnlyList enemyTypes = + [ + new("enemy.serial", "Serial Enemy", 12, 150, ["behavior.enemy.serial"], ["collectible.points.small"]), + new("enemy.parallel", "Parallel Enemy", 18, 250, ["behavior.enemy.parallel"], ["collectible.squadron.orbit"]) + ]; + + IReadOnlyList enemyClusters = + [ + new("cluster.opening", "Opening Cluster", [ + new SpawnScheduleEntryDefinition("enemy.serial", 1.0, 1.1, 0.25), + new SpawnScheduleEntryDefinition("enemy.parallel", 3.0, 1.15, 0.7) + ], 500, ClusterEscapeRule.PreventReward) + ]; + + IReadOnlyList weapons = + [ + new("weapon.primary.basic", "Basic Primary", WeaponKind.PrimaryBallistic, 3, 0.18, 640.0, 1, false), + new("weapon.secondary.vertical", "Vertical Secondary", WeaponKind.SecondaryVertical, 2, 0.35, 500.0, 1, false) + ]; + + IReadOnlyList specialWeapons = + [ + new("weapon.special.bomb", "Bomb Special", SpecialWeaponKind.Bomb, 12, 3, 30) + ]; + + IReadOnlyList squadronMateTypes = + [ + new("squadron.orbit", "Orbit Mate", SquadronMateFormationKind.Orbit, 48.0, 10.0) + ]; + + IReadOnlyList missions = + [ + new("mission.test", "Test Mission", "difficulty.normal", "camera.test.path", ["layer.background.stars"], ["layer.foreground.clouds"], ["cluster.opening"], ["collectible.points.small", "collectible.squadron.orbit"], ["weapon.special.bomb"], ["intro", "cluster.opening", "boss.intro", "outro"]) + ]; + + return new ContentRegistry(missions, difficulties, cameraPaths, levelLayers, enemyTypes, enemyBehaviors, enemyClusters, collectibles, weapons, specialWeapons, squadronMateTypes); + } + + private static DifficultyDefinition CreateDifficulty(string id, string displayName, double challengeMultiplier, int shields, int retries, double scoreMultiplier) + { + return new DifficultyDefinition(id, displayName, new DifficultyModifierSet(challengeMultiplier, challengeMultiplier, challengeMultiplier, challengeMultiplier, 1.0 / challengeMultiplier, challengeMultiplier, 1.0 / challengeMultiplier, 1.0, 1.0, scoreMultiplier), shields, retries); + } +} \ No newline at end of file diff --git a/godot/scripts/content/samples/SampleContent.cs.uid b/godot/scripts/content/samples/SampleContent.cs.uid new file mode 100644 index 0000000..d1e54a5 --- /dev/null +++ b/godot/scripts/content/samples/SampleContent.cs.uid @@ -0,0 +1 @@ +uid://fchmkl772mpk diff --git a/godot/scripts/content/validation/ContentValidationMessage.cs b/godot/scripts/content/validation/ContentValidationMessage.cs new file mode 100644 index 0000000..00a40ab --- /dev/null +++ b/godot/scripts/content/validation/ContentValidationMessage.cs @@ -0,0 +1,5 @@ +#nullable enable + +namespace SideScrollerGame.Content.Validation; + +public sealed record ContentValidationMessage(ContentValidationSeverity Severity, string Code, string Message, string? DefinitionId = null); \ No newline at end of file diff --git a/godot/scripts/content/validation/ContentValidationMessage.cs.uid b/godot/scripts/content/validation/ContentValidationMessage.cs.uid new file mode 100644 index 0000000..bc7943e --- /dev/null +++ b/godot/scripts/content/validation/ContentValidationMessage.cs.uid @@ -0,0 +1 @@ +uid://8qcy6e8iuhae diff --git a/godot/scripts/content/validation/ContentValidationResult.cs b/godot/scripts/content/validation/ContentValidationResult.cs new file mode 100644 index 0000000..b183b7e --- /dev/null +++ b/godot/scripts/content/validation/ContentValidationResult.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; +using System.Linq; + +namespace SideScrollerGame.Content.Validation; + +public sealed class ContentValidationResult +{ + public ContentValidationResult(IEnumerable messages) + { + Messages = messages.ToList(); + } + + public IReadOnlyList Messages { get; } + + public bool HasErrors => Messages.Any(message => message.Severity == ContentValidationSeverity.Error); +} \ No newline at end of file diff --git a/godot/scripts/content/validation/ContentValidationResult.cs.uid b/godot/scripts/content/validation/ContentValidationResult.cs.uid new file mode 100644 index 0000000..b98a7cc --- /dev/null +++ b/godot/scripts/content/validation/ContentValidationResult.cs.uid @@ -0,0 +1 @@ +uid://dbi1uj5y5xpa5 diff --git a/godot/scripts/content/validation/ContentValidationSeverity.cs b/godot/scripts/content/validation/ContentValidationSeverity.cs new file mode 100644 index 0000000..30a7893 --- /dev/null +++ b/godot/scripts/content/validation/ContentValidationSeverity.cs @@ -0,0 +1,8 @@ +namespace SideScrollerGame.Content.Validation; + +public enum ContentValidationSeverity +{ + Info, + Warning, + Error +} \ No newline at end of file diff --git a/godot/scripts/content/validation/ContentValidationSeverity.cs.uid b/godot/scripts/content/validation/ContentValidationSeverity.cs.uid new file mode 100644 index 0000000..144850b --- /dev/null +++ b/godot/scripts/content/validation/ContentValidationSeverity.cs.uid @@ -0,0 +1 @@ +uid://c2kwhf3c7snb7 diff --git a/godot/scripts/content/validation/ContentValidator.cs b/godot/scripts/content/validation/ContentValidator.cs new file mode 100644 index 0000000..863f678 --- /dev/null +++ b/godot/scripts/content/validation/ContentValidator.cs @@ -0,0 +1,374 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using SideScrollerGame.Content.Definitions; + +namespace SideScrollerGame.Content.Validation; + +public sealed class ContentValidator +{ + public ContentValidationResult Validate(ContentRegistry registry) + { + List messages = new(); + + ValidateDuplicateIds(registry, messages); + ValidateDifficulties(registry, messages); + ValidateCameraPaths(registry, messages); + ValidateLayers(registry, messages); + ValidateBehaviors(registry, messages); + ValidateEnemies(registry, messages); + ValidateClusters(registry, messages); + ValidateCollectibles(registry, messages); + ValidateWeapons(registry, messages); + ValidateSpecialWeapons(registry, messages); + ValidateSquadronMateTypes(registry, messages); + ValidateMissions(registry, messages); + + return new ContentValidationResult(messages); + } + + private static void ValidateDuplicateIds(ContentRegistry registry, List messages) + { + foreach (IGrouping group in registry.AllDefinitions().GroupBy(definition => definition.Id)) + { + if (string.IsNullOrWhiteSpace(group.Key)) + { + AddError(messages, "content.id.empty", "A definition has an empty id."); + } + else if (group.Count() > 1) + { + string kinds = string.Join(", ", group.Select(definition => definition.Kind)); + AddError(messages, "content.id.duplicate", $"Duplicate definition id '{group.Key}' appears in {kinds}.", group.Key); + } + } + } + + private static void ValidateDifficulties(ContentRegistry registry, List messages) + { + foreach (DifficultyDefinition difficulty in registry.DifficultyDefinitions) + { + if (difficulty.HeroStartingShieldCharges < 0) + { + AddError(messages, "difficulty.shields.invalid", $"Difficulty '{difficulty.Id}' has negative starting shields.", difficulty.Id); + } + + if (difficulty.HeroRetryCount < 0) + { + AddError(messages, "difficulty.retries.invalid", $"Difficulty '{difficulty.Id}' has negative retry count.", difficulty.Id); + } + + ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyHealthMultiplier", difficulty.Modifiers.EnemyHealthMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyProjectileSpeedMultiplier", difficulty.Modifiers.EnemyProjectileSpeedMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyFireCadenceMultiplier", difficulty.Modifiers.EnemyFireCadenceMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "EnemySpawnDensityMultiplier", difficulty.Modifiers.EnemySpawnDensityMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "ClusterSpawnIntervalMultiplier", difficulty.Modifiers.ClusterSpawnIntervalMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "BossHealthMultiplier", difficulty.Modifiers.BossHealthMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "BossPhaseTimingMultiplier", difficulty.Modifiers.BossPhaseTimingMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "CollectibleDropRateMultiplier", difficulty.Modifiers.CollectibleDropRateMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "SpecialWeaponInitialAmmoMultiplier", difficulty.Modifiers.SpecialWeaponInitialAmmoMultiplier); + ValidatePositiveMultiplier(messages, difficulty.Id, "ScoreMultiplier", difficulty.Modifiers.ScoreMultiplier); + } + } + + private static void ValidateCameraPaths(ContentRegistry registry, List messages) + { + foreach (CameraPathDefinition cameraPath in registry.CameraPathDefinitions) + { + if (cameraPath.Points.Count == 0) + { + AddError(messages, "camera.points.empty", $"Camera path '{cameraPath.Id}' has no points.", cameraPath.Id); + } + + if (cameraPath.DefaultSpeed <= 0.0) + { + AddError(messages, "camera.speed.invalid", $"Camera path '{cameraPath.Id}' has non-positive default speed.", cameraPath.Id); + } + + foreach (CameraPathPointDefinition point in cameraPath.Points) + { + if (point.TimeSeconds < 0.0) + { + AddError(messages, "camera.point.time.invalid", $"Camera path '{cameraPath.Id}' has a point before mission start.", cameraPath.Id); + } + + if (point.Speed <= 0.0) + { + AddError(messages, "camera.point.speed.invalid", $"Camera path '{cameraPath.Id}' has a point with non-positive speed.", cameraPath.Id); + } + } + } + } + + private static void ValidateLayers(ContentRegistry registry, List messages) + { + foreach (LevelLayerDefinition layer in registry.LevelLayerDefinitions) + { + if (layer.ScrollFactor < 0.0) + { + AddError(messages, "layer.scroll.invalid", $"Layer '{layer.Id}' has negative scroll factor.", layer.Id); + } + } + } + + private static void ValidateBehaviors(ContentRegistry registry, List messages) + { + foreach (EnemyBehaviorDefinition behavior in registry.EnemyBehaviorDefinitions) + { + if (behavior.Tracks.Count == 0) + { + AddError(messages, "behavior.tracks.empty", $"Behavior '{behavior.Id}' has no tracks.", behavior.Id); + } + + foreach (BehaviorTrackDefinition track in behavior.Tracks) + { + if (track.Events.Count == 0) + { + AddError(messages, "behavior.track.events.empty", $"Behavior '{behavior.Id}' track '{track.Id}' has no events.", behavior.Id); + } + + foreach (BehaviorEventDefinition behaviorEvent in track.Events) + { + if (behaviorEvent.StartSeconds < 0.0) + { + AddError(messages, "behavior.event.start.invalid", $"Behavior '{behavior.Id}' has an event before mission start.", behavior.Id); + } + + if (behaviorEvent.DurationSeconds < 0.0) + { + AddError(messages, "behavior.event.duration.invalid", $"Behavior '{behavior.Id}' has an event with negative duration.", behavior.Id); + } + } + } + } + } + + private static void ValidateEnemies(ContentRegistry registry, List messages) + { + foreach (EnemyTypeDefinition enemyType in registry.EnemyTypeDefinitions) + { + if (enemyType.Health <= 0) + { + AddError(messages, "enemy.health.invalid", $"Enemy '{enemyType.Id}' has non-positive health.", enemyType.Id); + } + + if (enemyType.ScoreValue < 0) + { + AddError(messages, "enemy.score.invalid", $"Enemy '{enemyType.Id}' has negative score value.", enemyType.Id); + } + + if (enemyType.BehaviorIds.Count == 0) + { + AddError(messages, "enemy.behaviors.empty", $"Enemy '{enemyType.Id}' has no behavior references.", enemyType.Id); + } + + foreach (string behaviorId in enemyType.BehaviorIds) + { + if (!registry.EnemyBehaviors.ContainsKey(behaviorId)) + { + AddError(messages, "enemy.behavior.missing", $"Enemy '{enemyType.Id}' references missing behavior '{behaviorId}'.", enemyType.Id); + } + } + + foreach (string collectibleId in enemyType.DropCollectibleIds) + { + if (!registry.Collectibles.ContainsKey(collectibleId)) + { + AddError(messages, "enemy.collectible.missing", $"Enemy '{enemyType.Id}' references missing drop collectible '{collectibleId}'.", enemyType.Id); + } + } + } + } + + private static void ValidateClusters(ContentRegistry registry, List messages) + { + foreach (EnemyClusterDefinition cluster in registry.EnemyClusterDefinitions) + { + if (cluster.Spawns.Count == 0) + { + AddError(messages, "cluster.spawns.empty", $"Cluster '{cluster.Id}' has no spawns.", cluster.Id); + } + + if (cluster.CompletionRewardPoints < 0) + { + AddError(messages, "cluster.reward.invalid", $"Cluster '{cluster.Id}' has negative completion reward.", cluster.Id); + } + + foreach (SpawnScheduleEntryDefinition spawn in cluster.Spawns) + { + if (spawn.SpawnTimeSeconds < 0.0) + { + AddError(messages, "cluster.spawn.time.invalid", $"Cluster '{cluster.Id}' has a spawn before mission start.", cluster.Id); + } + + if (!registry.EnemyTypes.ContainsKey(spawn.EnemyTypeId)) + { + AddError(messages, "cluster.enemy.missing", $"Cluster '{cluster.Id}' references missing enemy type '{spawn.EnemyTypeId}'.", cluster.Id); + } + } + } + } + + private static void ValidateCollectibles(ContentRegistry registry, List messages) + { + foreach (CollectibleDefinition collectible in registry.CollectibleDefinitions) + { + if (collectible.Value < 0) + { + AddError(messages, "collectible.value.invalid", $"Collectible '{collectible.Id}' has negative value.", collectible.Id); + } + + if (collectible.Kind == CollectibleKind.PrimaryWeapon && !ReferencedContentExists(registry.Weapons, collectible.ReferencedContentId)) + { + AddError(messages, "collectible.weapon.missing", $"Collectible '{collectible.Id}' references missing primary weapon '{collectible.ReferencedContentId}'.", collectible.Id); + } + + if (collectible.Kind == CollectibleKind.SecondaryWeapon && !ReferencedContentExists(registry.Weapons, collectible.ReferencedContentId)) + { + AddError(messages, "collectible.weapon.missing", $"Collectible '{collectible.Id}' references missing secondary weapon '{collectible.ReferencedContentId}'.", collectible.Id); + } + + if (collectible.Kind == CollectibleKind.SpecialAmmo && !ReferencedContentExists(registry.SpecialWeapons, collectible.ReferencedContentId)) + { + AddError(messages, "collectible.special.missing", $"Collectible '{collectible.Id}' references missing special weapon '{collectible.ReferencedContentId}'.", collectible.Id); + } + + if (collectible.Kind == CollectibleKind.SquadronMate && !ReferencedContentExists(registry.SquadronMateTypes, collectible.ReferencedContentId)) + { + AddError(messages, "collectible.squadron.missing", $"Collectible '{collectible.Id}' references missing squadron mate type '{collectible.ReferencedContentId}'.", collectible.Id); + } + } + } + + private static void ValidateWeapons(ContentRegistry registry, List messages) + { + foreach (WeaponDefinition weapon in registry.WeaponDefinitions) + { + if (weapon.Damage <= 0) + { + AddError(messages, "weapon.damage.invalid", $"Weapon '{weapon.Id}' has non-positive damage.", weapon.Id); + } + + if (weapon.FireCadenceSeconds <= 0.0) + { + AddError(messages, "weapon.cadence.invalid", $"Weapon '{weapon.Id}' has non-positive fire cadence.", weapon.Id); + } + + if (weapon.ProjectileSpeed <= 0.0) + { + AddError(messages, "weapon.speed.invalid", $"Weapon '{weapon.Id}' has non-positive projectile speed.", weapon.Id); + } + + if (weapon.ProjectileCount <= 0) + { + AddError(messages, "weapon.projectiles.invalid", $"Weapon '{weapon.Id}' has non-positive projectile count.", weapon.Id); + } + } + } + + private static void ValidateSpecialWeapons(ContentRegistry registry, List messages) + { + foreach (SpecialWeaponDefinition specialWeapon in registry.SpecialWeaponDefinitions) + { + if (specialWeapon.InitialAmmo < 0) + { + AddError(messages, "special.ammo.invalid", $"Special weapon '{specialWeapon.Id}' has negative initial ammo.", specialWeapon.Id); + } + + if (specialWeapon.AmmoPickupAmount < 0) + { + AddError(messages, "special.pickup.invalid", $"Special weapon '{specialWeapon.Id}' has negative pickup ammo.", specialWeapon.Id); + } + + if (specialWeapon.Damage <= 0) + { + AddError(messages, "special.damage.invalid", $"Special weapon '{specialWeapon.Id}' has non-positive damage.", specialWeapon.Id); + } + } + } + + private static void ValidateSquadronMateTypes(ContentRegistry registry, List messages) + { + foreach (SquadronMateTypeDefinition squadronMateType in registry.SquadronMateTypeDefinitions) + { + if (squadronMateType.Spacing < 0.0) + { + AddError(messages, "squadron.spacing.invalid", $"Squadron mate type '{squadronMateType.Id}' has negative spacing.", squadronMateType.Id); + } + + if (squadronMateType.MovementSmoothing < 0.0) + { + AddError(messages, "squadron.smoothing.invalid", $"Squadron mate type '{squadronMateType.Id}' has negative movement smoothing.", squadronMateType.Id); + } + } + } + + private static void ValidateMissions(ContentRegistry registry, List messages) + { + foreach (MissionDefinition mission in registry.MissionDefinitions) + { + RequireReference(messages, mission.Id, mission.DefaultDifficultyId, registry.Difficulties, "mission.difficulty.missing", "default difficulty"); + RequireReference(messages, mission.Id, mission.CameraPathId, registry.CameraPaths, "mission.camera.missing", "camera path"); + RequireReferences(messages, mission.Id, mission.BackgroundLayerIds, registry.LevelLayers, "mission.background.missing", "background layer"); + RequireReferences(messages, mission.Id, mission.ForegroundLayerIds, registry.LevelLayers, "mission.foreground.missing", "foreground layer"); + RequireReferences(messages, mission.Id, mission.ClusterIds, registry.EnemyClusters, "mission.cluster.missing", "cluster"); + RequireReferences(messages, mission.Id, mission.CollectibleIds, registry.Collectibles, "mission.collectible.missing", "collectible"); + RequireReferences(messages, mission.Id, mission.SpecialWeaponIds, registry.SpecialWeapons, "mission.special.missing", "special weapon"); + + if (mission.BackgroundLayerIds.Count == 0) + { + AddError(messages, "mission.background.empty", $"Mission '{mission.Id}' has no background layers.", mission.Id); + } + + if (mission.ForegroundLayerIds.Count == 0) + { + AddError(messages, "mission.foreground.empty", $"Mission '{mission.Id}' has no foreground layers.", mission.Id); + } + + if (mission.ClusterIds.Count == 0) + { + AddError(messages, "mission.clusters.empty", $"Mission '{mission.Id}' has no clusters.", mission.Id); + } + + if (mission.TimelineMarkers.Count == 0) + { + AddError(messages, "mission.markers.empty", $"Mission '{mission.Id}' has no timeline markers.", mission.Id); + } + } + } + + private static void ValidatePositiveMultiplier(List messages, string definitionId, string fieldName, double value) + { + if (value <= 0.0 || double.IsNaN(value) || double.IsInfinity(value)) + { + AddError(messages, "difficulty.multiplier.invalid", $"Difficulty '{definitionId}' has non-positive {fieldName}.", definitionId); + } + } + + private static bool ReferencedContentExists(IReadOnlyDictionary definitions, string? id) + { + return !string.IsNullOrWhiteSpace(id) && definitions.ContainsKey(id); + } + + private static void RequireReferences(List messages, string ownerId, IEnumerable referenceIds, IReadOnlyDictionary definitions, string code, string referenceKind) + { + foreach (string referenceId in referenceIds) + { + RequireReference(messages, ownerId, referenceId, definitions, code, referenceKind); + } + } + + private static void RequireReference(List messages, string ownerId, string referenceId, IReadOnlyDictionary definitions, string code, string referenceKind) + { + if (!definitions.ContainsKey(referenceId)) + { + AddError(messages, code, $"Definition '{ownerId}' references missing {referenceKind} '{referenceId}'.", ownerId); + } + } + + private static void AddError(List messages, string code, string message, string? definitionId = null) + { + messages.Add(new ContentValidationMessage(ContentValidationSeverity.Error, code, message, definitionId)); + } +} \ No newline at end of file diff --git a/godot/scripts/content/validation/ContentValidator.cs.uid b/godot/scripts/content/validation/ContentValidator.cs.uid new file mode 100644 index 0000000..9a3542c --- /dev/null +++ b/godot/scripts/content/validation/ContentValidator.cs.uid @@ -0,0 +1 @@ +uid://chj207coyjf4j diff --git a/godot/scripts/debug/ContentBrowserController.cs b/godot/scripts/debug/ContentBrowserController.cs new file mode 100644 index 0000000..6352054 --- /dev/null +++ b/godot/scripts/debug/ContentBrowserController.cs @@ -0,0 +1,80 @@ +#nullable enable + +using System; +using System.Linq; +using System.Text; +using Godot; +using SideScrollerGame.Content; +using SideScrollerGame.Content.Samples; +using SideScrollerGame.Content.Validation; + +namespace SideScrollerGame.Debug; + +public partial class ContentBrowserController : Control +{ + public override void _Ready() + { + ContentRegistry registry = SampleContent.CreateRegistry(); + ContentValidationResult validation = new ContentValidator().Validate(registry); + string report = BuildReport(registry, validation); + SetText(report); + + if (!ShouldValidateAndQuit()) + { + return; + } + + GD.Print(report); + GD.Print(validation.HasErrors ? "Content validation failed" : "Content validation succeeded"); + GetTree().Quit(validation.HasErrors ? 1 : 0); + } + + private static string BuildReport(ContentRegistry registry, ContentValidationResult validation) + { + StringBuilder builder = new(); + builder.AppendLine("Loaded content definitions:"); + foreach (string id in registry.AllDefinitionIds()) + { + builder.AppendLine(id); + } + + if (validation.Messages.Count > 0) + { + builder.AppendLine(); + builder.AppendLine("Validation messages:"); + foreach (ContentValidationMessage message in validation.Messages) + { + builder.AppendLine($"{message.Severity}: {message.Code}: {message.Message}"); + } + } + + return builder.ToString().TrimEnd(); + } + + private static bool ShouldValidateAndQuit() + { + return IsHeadless() && OS.GetCmdlineUserArgs().Any(argument => argument.Equals(ValidateOnlyArgument, StringComparison.OrdinalIgnoreCase)); + } + + private static bool IsHeadless() + { + return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase); + } + + private void SetText(string report) + { + Label? title = GetNodeOrNull