Add core content definitions
This commit is contained in:
65
SLICE2.MD
65
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
39
godot/scenes/debug/ContentBrowser.tscn
Normal file
39
godot/scenes/debug/ContentBrowser.tscn
Normal file
@@ -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..."
|
||||
@@ -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;
|
||||
}
|
||||
130
godot/scripts/content/ContentRegistry.cs
Normal file
130
godot/scripts/content/ContentRegistry.cs
Normal file
@@ -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<MissionDefinition> missions, IEnumerable<DifficultyDefinition> difficulties, IEnumerable<CameraPathDefinition> cameraPaths, IEnumerable<LevelLayerDefinition> levelLayers, IEnumerable<EnemyTypeDefinition> enemyTypes, IEnumerable<EnemyBehaviorDefinition> enemyBehaviors, IEnumerable<EnemyClusterDefinition> enemyClusters, IEnumerable<CollectibleDefinition> collectibles, IEnumerable<WeaponDefinition> weapons, IEnumerable<SpecialWeaponDefinition> specialWeapons, IEnumerable<SquadronMateTypeDefinition> 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<string> AllDefinitionIds()
|
||||
{
|
||||
return AllDefinitions().Select(definition => definition.Id).OrderBy(id => id);
|
||||
}
|
||||
|
||||
public IReadOnlyList<DefinitionEntry> 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<MissionDefinition> MissionDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<DifficultyDefinition> DifficultyDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<CameraPathDefinition> CameraPathDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<LevelLayerDefinition> LevelLayerDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<EnemyTypeDefinition> EnemyTypeDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<EnemyBehaviorDefinition> EnemyBehaviorDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<EnemyClusterDefinition> EnemyClusterDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<CollectibleDefinition> CollectibleDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<WeaponDefinition> WeaponDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<SpecialWeaponDefinition> SpecialWeaponDefinitions { get; }
|
||||
|
||||
public IReadOnlyList<SquadronMateTypeDefinition> SquadronMateTypeDefinitions { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, MissionDefinition> Missions { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, DifficultyDefinition> Difficulties { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, CameraPathDefinition> CameraPaths { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, LevelLayerDefinition> LevelLayers { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, EnemyTypeDefinition> EnemyTypes { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, EnemyBehaviorDefinition> EnemyBehaviors { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, EnemyClusterDefinition> EnemyClusters { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, CollectibleDefinition> Collectibles { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, WeaponDefinition> Weapons { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, SpecialWeaponDefinition> SpecialWeapons { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, SquadronMateTypeDefinition> SquadronMateTypes { get; }
|
||||
|
||||
private static IReadOnlyDictionary<string, TDefinition> ToDictionary<TDefinition>(IEnumerable<TDefinition> definitions) where TDefinition : class
|
||||
{
|
||||
return definitions.GroupBy(GetId).Select(group => group.First()).ToDictionary(GetId);
|
||||
}
|
||||
|
||||
private static string GetId<TDefinition>(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
|
||||
};
|
||||
}
|
||||
}
|
||||
1
godot/scripts/content/ContentRegistry.cs.uid
Normal file
1
godot/scripts/content/ContentRegistry.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ced4h8gt2he8a
|
||||
3
godot/scripts/content/DefinitionEntry.cs
Normal file
3
godot/scripts/content/DefinitionEntry.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace SideScrollerGame.Content;
|
||||
|
||||
public sealed record DefinitionEntry(string Kind, string Id);
|
||||
1
godot/scripts/content/DefinitionEntry.cs.uid
Normal file
1
godot/scripts/content/DefinitionEntry.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dlbg73ke5c56n
|
||||
@@ -0,0 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record BehaviorEventDefinition(BehaviorEventKind Kind, double StartSeconds, double DurationSeconds, string? ReferenceId = null);
|
||||
@@ -0,0 +1 @@
|
||||
uid://bfhvtjvxbesyr
|
||||
12
godot/scripts/content/definitions/BehaviorEventKind.cs
Normal file
12
godot/scripts/content/definitions/BehaviorEventKind.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum BehaviorEventKind
|
||||
{
|
||||
Wait,
|
||||
MovePath,
|
||||
RotatePath,
|
||||
FireProjectile,
|
||||
ChangeSpeed,
|
||||
SpawnChild,
|
||||
TriggerEffect
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3k6pfrvkik72
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record BehaviorTrackDefinition(string Id, BehaviorTrackMode Mode, IReadOnlyList<BehaviorEventDefinition> Events);
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8sossiey58sy
|
||||
7
godot/scripts/content/definitions/BehaviorTrackMode.cs
Normal file
7
godot/scripts/content/definitions/BehaviorTrackMode.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum BehaviorTrackMode
|
||||
{
|
||||
Serial,
|
||||
Parallel
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cj7orw08m62rq
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record CameraPathDefinition(string Id, string DisplayName, IReadOnlyList<CameraPathPointDefinition> Points, double DefaultSpeed);
|
||||
@@ -0,0 +1 @@
|
||||
uid://1sohi8c0b4p4
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record CameraPathPointDefinition(double TimeSeconds, double X, double Y, double Speed);
|
||||
@@ -0,0 +1 @@
|
||||
uid://n5yggkiyqu3u
|
||||
8
godot/scripts/content/definitions/ClusterEscapeRule.cs
Normal file
8
godot/scripts/content/definitions/ClusterEscapeRule.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum ClusterEscapeRule
|
||||
{
|
||||
PreventReward,
|
||||
AllowReward,
|
||||
RespawnUntilDestroyed
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bo12ujllh0vko
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
uid://dojpxw2gdr0yo
|
||||
12
godot/scripts/content/definitions/CollectibleKind.cs
Normal file
12
godot/scripts/content/definitions/CollectibleKind.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum CollectibleKind
|
||||
{
|
||||
Points,
|
||||
PrimaryWeapon,
|
||||
SecondaryWeapon,
|
||||
ClearScreen,
|
||||
ShieldCharge,
|
||||
SpecialAmmo,
|
||||
SquadronMate
|
||||
}
|
||||
1
godot/scripts/content/definitions/CollectibleKind.cs.uid
Normal file
1
godot/scripts/content/definitions/CollectibleKind.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bbuwcsopsg4on
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record DifficultyDefinition(string Id, string DisplayName, DifficultyModifierSet Modifiers, int HeroStartingShieldCharges, int HeroRetryCount);
|
||||
@@ -0,0 +1 @@
|
||||
uid://8lf15cyl337v
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
uid://c3wiaioy3cj2l
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record EnemyBehaviorDefinition(string Id, string DisplayName, IReadOnlyList<BehaviorTrackDefinition> Tracks);
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7qggd0xsyl8v
|
||||
@@ -0,0 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record EnemyClusterDefinition(string Id, string DisplayName, IReadOnlyList<SpawnScheduleEntryDefinition> Spawns, int CompletionRewardPoints, ClusterEscapeRule EscapeRule);
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5lqrpv5gc753
|
||||
5
godot/scripts/content/definitions/EnemyTypeDefinition.cs
Normal file
5
godot/scripts/content/definitions/EnemyTypeDefinition.cs
Normal file
@@ -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<string> BehaviorIds, IReadOnlyList<string> DropCollectibleIds);
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgsajqr5fvw5g
|
||||
8
godot/scripts/content/definitions/LayerKind.cs
Normal file
8
godot/scripts/content/definitions/LayerKind.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum LayerKind
|
||||
{
|
||||
Background,
|
||||
Interactive,
|
||||
Foreground
|
||||
}
|
||||
1
godot/scripts/content/definitions/LayerKind.cs.uid
Normal file
1
godot/scripts/content/definitions/LayerKind.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dp41dwk68qlin
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record LevelLayerDefinition(string Id, string DisplayName, LayerKind Kind, double ScrollFactor, bool Repeats);
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfikxuceq8ros
|
||||
5
godot/scripts/content/definitions/MissionDefinition.cs
Normal file
5
godot/scripts/content/definitions/MissionDefinition.cs
Normal file
@@ -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<string> BackgroundLayerIds, IReadOnlyList<string> ForegroundLayerIds, IReadOnlyList<string> ClusterIds, IReadOnlyList<string> CollectibleIds, IReadOnlyList<string> SpecialWeaponIds, IReadOnlyList<string> TimelineMarkers);
|
||||
@@ -0,0 +1 @@
|
||||
uid://d1gwpoe4obx5t
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record SpawnScheduleEntryDefinition(string EnemyTypeId, double SpawnTimeSeconds, double AnchorX, double AnchorY);
|
||||
@@ -0,0 +1 @@
|
||||
uid://crrtalqhddqc3
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record SpecialWeaponDefinition(string Id, string DisplayName, SpecialWeaponKind Kind, int InitialAmmo, int AmmoPickupAmount, int Damage);
|
||||
@@ -0,0 +1 @@
|
||||
uid://b7rncbmm228x6
|
||||
9
godot/scripts/content/definitions/SpecialWeaponKind.cs
Normal file
9
godot/scripts/content/definitions/SpecialWeaponKind.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum SpecialWeaponKind
|
||||
{
|
||||
Bomb,
|
||||
Crawler,
|
||||
Napalm,
|
||||
BlackHole
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://cr8u8s2r2vlj5
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum SquadronMateFormationKind
|
||||
{
|
||||
Hug,
|
||||
Orbit,
|
||||
LineFormation,
|
||||
VFormation,
|
||||
Follow
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://trmmvv4tfq0l
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public sealed record SquadronMateTypeDefinition(string Id, string DisplayName, SquadronMateFormationKind Formation, double Spacing, double MovementSmoothing);
|
||||
@@ -0,0 +1 @@
|
||||
uid://b0n4orx2ffcu2
|
||||
3
godot/scripts/content/definitions/WeaponDefinition.cs
Normal file
3
godot/scripts/content/definitions/WeaponDefinition.cs
Normal file
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
uid://br35s1xq4dspu
|
||||
12
godot/scripts/content/definitions/WeaponKind.cs
Normal file
12
godot/scripts/content/definitions/WeaponKind.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SideScrollerGame.Content.Definitions;
|
||||
|
||||
public enum WeaponKind
|
||||
{
|
||||
PrimaryBallistic,
|
||||
PrimarySeeking,
|
||||
PrimaryLaser,
|
||||
PrimaryGrenadeCluster,
|
||||
SecondaryVertical,
|
||||
SecondaryDiagonal,
|
||||
SecondaryBackward
|
||||
}
|
||||
1
godot/scripts/content/definitions/WeaponKind.cs.uid
Normal file
1
godot/scripts/content/definitions/WeaponKind.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bbc7rfx8wc610
|
||||
99
godot/scripts/content/samples/SampleContent.cs
Normal file
99
godot/scripts/content/samples/SampleContent.cs
Normal file
@@ -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<DifficultyDefinition> 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<CameraPathDefinition> 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<LevelLayerDefinition> levelLayers =
|
||||
[
|
||||
new("layer.background.stars", "Repeating Background", LayerKind.Background, 0.35, true),
|
||||
new("layer.foreground.clouds", "Repeating Foreground", LayerKind.Foreground, 1.4, true)
|
||||
];
|
||||
|
||||
IReadOnlyList<EnemyBehaviorDefinition> 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<CollectibleDefinition> collectibles =
|
||||
[
|
||||
new("collectible.points.small", "Small Points", CollectibleKind.Points, 100),
|
||||
new("collectible.squadron.orbit", "Orbit Squadron Mate", CollectibleKind.SquadronMate, 1, "squadron.orbit")
|
||||
];
|
||||
|
||||
IReadOnlyList<EnemyTypeDefinition> 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<EnemyClusterDefinition> 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<WeaponDefinition> 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<SpecialWeaponDefinition> specialWeapons =
|
||||
[
|
||||
new("weapon.special.bomb", "Bomb Special", SpecialWeaponKind.Bomb, 12, 3, 30)
|
||||
];
|
||||
|
||||
IReadOnlyList<SquadronMateTypeDefinition> squadronMateTypes =
|
||||
[
|
||||
new("squadron.orbit", "Orbit Mate", SquadronMateFormationKind.Orbit, 48.0, 10.0)
|
||||
];
|
||||
|
||||
IReadOnlyList<MissionDefinition> 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);
|
||||
}
|
||||
}
|
||||
1
godot/scripts/content/samples/SampleContent.cs.uid
Normal file
1
godot/scripts/content/samples/SampleContent.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://fchmkl772mpk
|
||||
@@ -0,0 +1,5 @@
|
||||
#nullable enable
|
||||
|
||||
namespace SideScrollerGame.Content.Validation;
|
||||
|
||||
public sealed record ContentValidationMessage(ContentValidationSeverity Severity, string Code, string Message, string? DefinitionId = null);
|
||||
@@ -0,0 +1 @@
|
||||
uid://8qcy6e8iuhae
|
||||
16
godot/scripts/content/validation/ContentValidationResult.cs
Normal file
16
godot/scripts/content/validation/ContentValidationResult.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace SideScrollerGame.Content.Validation;
|
||||
|
||||
public sealed class ContentValidationResult
|
||||
{
|
||||
public ContentValidationResult(IEnumerable<ContentValidationMessage> messages)
|
||||
{
|
||||
Messages = messages.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<ContentValidationMessage> Messages { get; }
|
||||
|
||||
public bool HasErrors => Messages.Any(message => message.Severity == ContentValidationSeverity.Error);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbi1uj5y5xpa5
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace SideScrollerGame.Content.Validation;
|
||||
|
||||
public enum ContentValidationSeverity
|
||||
{
|
||||
Info,
|
||||
Warning,
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
uid://c2kwhf3c7snb7
|
||||
374
godot/scripts/content/validation/ContentValidator.cs
Normal file
374
godot/scripts/content/validation/ContentValidator.cs
Normal file
@@ -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<ContentValidationMessage> 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<ContentValidationMessage> messages)
|
||||
{
|
||||
foreach (IGrouping<string, DefinitionEntry> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<ContentValidationMessage> 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<TDefinition>(IReadOnlyDictionary<string, TDefinition> definitions, string? id)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(id) && definitions.ContainsKey(id);
|
||||
}
|
||||
|
||||
private static void RequireReferences<TDefinition>(List<ContentValidationMessage> messages, string ownerId, IEnumerable<string> referenceIds, IReadOnlyDictionary<string, TDefinition> definitions, string code, string referenceKind)
|
||||
{
|
||||
foreach (string referenceId in referenceIds)
|
||||
{
|
||||
RequireReference(messages, ownerId, referenceId, definitions, code, referenceKind);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireReference<TDefinition>(List<ContentValidationMessage> messages, string ownerId, string referenceId, IReadOnlyDictionary<string, TDefinition> definitions, string code, string referenceKind)
|
||||
{
|
||||
if (!definitions.ContainsKey(referenceId))
|
||||
{
|
||||
AddError(messages, code, $"Definition '{ownerId}' references missing {referenceKind} '{referenceId}'.", ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddError(List<ContentValidationMessage> messages, string code, string message, string? definitionId = null)
|
||||
{
|
||||
messages.Add(new ContentValidationMessage(ContentValidationSeverity.Error, code, message, definitionId));
|
||||
}
|
||||
}
|
||||
1
godot/scripts/content/validation/ContentValidator.cs.uid
Normal file
1
godot/scripts/content/validation/ContentValidator.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://chj207coyjf4j
|
||||
80
godot/scripts/debug/ContentBrowserController.cs
Normal file
80
godot/scripts/debug/ContentBrowserController.cs
Normal file
@@ -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<Label>("Title");
|
||||
if (title is not null)
|
||||
{
|
||||
title.Text = "Content Browser";
|
||||
}
|
||||
|
||||
Label? content = GetNodeOrNull<Label>("Scroll/Content");
|
||||
if (content is not null)
|
||||
{
|
||||
content.Text = report;
|
||||
}
|
||||
}
|
||||
|
||||
private const string ValidateOnlyArgument = "--content-validate-only";
|
||||
}
|
||||
1
godot/scripts/debug/ContentBrowserController.cs.uid
Normal file
1
godot/scripts/debug/ContentBrowserController.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cfukg3hg4wwpx
|
||||
@@ -3,5 +3,6 @@ namespace SideScrollerGame.Debug;
|
||||
public enum DebugBootMode
|
||||
{
|
||||
Menu,
|
||||
Smoke
|
||||
Smoke,
|
||||
ContentBrowser
|
||||
}
|
||||
@@ -81,7 +81,28 @@ public sealed class DebugSettings
|
||||
|
||||
private static bool TryParseBootMode(string value, out DebugBootMode bootMode)
|
||||
{
|
||||
return Enum.TryParse(value, true, out bootMode);
|
||||
if (Enum.TryParse(value, true, out bootMode))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
string normalizedValue = NormalizeBootModeName(value);
|
||||
foreach (DebugBootMode candidate in Enum.GetValues<DebugBootMode>())
|
||||
{
|
||||
if (NormalizeBootModeName(candidate.ToString()).Equals(normalizedValue, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bootMode = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bootMode = DebugBootMode.Menu;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeBootModeName(string value)
|
||||
{
|
||||
return value.Replace("-", string.Empty, StringComparison.Ordinal).Replace("_", string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private const string DebugBootModePrefix = "--debug-boot=";
|
||||
|
||||
111
tests/SideScrollerGame.Content.Tests/ContentValidationTests.cs
Normal file
111
tests/SideScrollerGame.Content.Tests/ContentValidationTests.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using SideScrollerGame.Content.Definitions;
|
||||
using SideScrollerGame.Content.Samples;
|
||||
using SideScrollerGame.Content.Validation;
|
||||
|
||||
namespace SideScrollerGame.Content.Tests;
|
||||
|
||||
public class ContentValidationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SampleContent_ValidatesWithoutErrors()
|
||||
{
|
||||
ContentRegistry registry = SampleContent.CreateRegistry();
|
||||
|
||||
ContentValidationResult result = new ContentValidator().Validate(registry);
|
||||
|
||||
Assert.False(result.HasErrors, string.Join(Environment.NewLine, result.Messages.Select(message => message.Message)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Registry_CanLookUpCoreSampleDefinitions()
|
||||
{
|
||||
ContentRegistry registry = SampleContent.CreateRegistry();
|
||||
|
||||
Assert.True(registry.TryGetMission("mission.test", out MissionDefinition? mission));
|
||||
Assert.NotNull(mission);
|
||||
Assert.Equal("Test Mission", mission.DisplayName);
|
||||
Assert.True(registry.TryGetEnemyType("enemy.serial", out EnemyTypeDefinition? enemy));
|
||||
Assert.NotNull(enemy);
|
||||
Assert.Equal(12, enemy.Health);
|
||||
Assert.True(registry.Weapons.ContainsKey("weapon.primary.basic"));
|
||||
Assert.True(registry.TryGetDifficulty("difficulty.normal", out DifficultyDefinition? difficulty));
|
||||
Assert.NotNull(difficulty);
|
||||
Assert.Equal(3, difficulty.HeroStartingShieldCharges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DuplicateIds_ReportsDuplicatedId()
|
||||
{
|
||||
ContentRegistry sample = SampleContent.CreateRegistry();
|
||||
ContentRegistry registry = CreateRegistry(sample, difficulties: sample.DifficultyDefinitions.Append(sample.DifficultyDefinitions[0] with { DisplayName = "Duplicate Easy" }).ToList());
|
||||
|
||||
ContentValidationResult result = new ContentValidator().Validate(registry);
|
||||
|
||||
Assert.Contains(result.Messages, message => message.Code == "content.id.duplicate" && message.Message.Contains("difficulty.easy", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_MissionWithMissingCluster_ReportsMissingCluster()
|
||||
{
|
||||
ContentRegistry sample = SampleContent.CreateRegistry();
|
||||
ContentRegistry registry = CreateRegistry(sample, missions:
|
||||
[
|
||||
sample.MissionDefinitions[0] with { ClusterIds = ["cluster.opening", "cluster.missing"] }
|
||||
]);
|
||||
|
||||
ContentValidationResult result = new ContentValidator().Validate(registry);
|
||||
|
||||
Assert.Contains(result.Messages, message => message.Code == "mission.cluster.missing" && message.DefinitionId == "mission.test" && message.Message.Contains("cluster.missing", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_ClusterWithMissingEnemyType_ReportsMissingEnemy()
|
||||
{
|
||||
ContentRegistry sample = SampleContent.CreateRegistry();
|
||||
ContentRegistry registry = CreateRegistry(sample, enemyClusters:
|
||||
[
|
||||
sample.EnemyClusterDefinitions[0] with { Spawns = [new SpawnScheduleEntryDefinition("enemy.missing", 1.0, 1.0, 0.5)] }
|
||||
]);
|
||||
|
||||
ContentValidationResult result = new ContentValidator().Validate(registry);
|
||||
|
||||
Assert.Contains(result.Messages, message => message.Code == "cluster.enemy.missing" && message.DefinitionId == "cluster.opening" && message.Message.Contains("enemy.missing", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_BehaviorTrackWithNoEvents_ReportsBehavior()
|
||||
{
|
||||
ContentRegistry sample = SampleContent.CreateRegistry();
|
||||
ContentRegistry registry = CreateRegistry(sample, enemyBehaviors:
|
||||
[
|
||||
sample.EnemyBehaviorDefinitions[0] with { Tracks = [new BehaviorTrackDefinition("track.empty", BehaviorTrackMode.Serial, [])] },
|
||||
sample.EnemyBehaviorDefinitions[1]
|
||||
]);
|
||||
|
||||
ContentValidationResult result = new ContentValidator().Validate(registry);
|
||||
|
||||
Assert.Contains(result.Messages, message => message.Code == "behavior.track.events.empty" && message.DefinitionId == "behavior.enemy.serial");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DifficultyWithNonPositiveMultiplier_ReportsDifficulty()
|
||||
{
|
||||
ContentRegistry sample = SampleContent.CreateRegistry();
|
||||
DifficultyDefinition invalidDifficulty = sample.DifficultyDefinitions[1] with { Modifiers = sample.DifficultyDefinitions[1].Modifiers with { EnemyHealthMultiplier = 0.0 } };
|
||||
ContentRegistry registry = CreateRegistry(sample, difficulties:
|
||||
[
|
||||
sample.DifficultyDefinitions[0],
|
||||
invalidDifficulty,
|
||||
sample.DifficultyDefinitions[2]
|
||||
]);
|
||||
|
||||
ContentValidationResult result = new ContentValidator().Validate(registry);
|
||||
|
||||
Assert.Contains(result.Messages, message => message.Code == "difficulty.multiplier.invalid" && message.DefinitionId == "difficulty.normal" && message.Message.Contains("EnemyHealthMultiplier", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static ContentRegistry CreateRegistry(ContentRegistry sample, IReadOnlyList<MissionDefinition>? missions = null, IReadOnlyList<DifficultyDefinition>? difficulties = null, IReadOnlyList<CameraPathDefinition>? cameraPaths = null, IReadOnlyList<LevelLayerDefinition>? levelLayers = null, IReadOnlyList<EnemyTypeDefinition>? enemyTypes = null, IReadOnlyList<EnemyBehaviorDefinition>? enemyBehaviors = null, IReadOnlyList<EnemyClusterDefinition>? enemyClusters = null, IReadOnlyList<CollectibleDefinition>? collectibles = null, IReadOnlyList<WeaponDefinition>? weapons = null, IReadOnlyList<SpecialWeaponDefinition>? specialWeapons = null, IReadOnlyList<SquadronMateTypeDefinition>? squadronMateTypes = null)
|
||||
{
|
||||
return new ContentRegistry(missions ?? sample.MissionDefinitions, difficulties ?? sample.DifficultyDefinitions, cameraPaths ?? sample.CameraPathDefinitions, levelLayers ?? sample.LevelLayerDefinitions, enemyTypes ?? sample.EnemyTypeDefinitions, enemyBehaviors ?? sample.EnemyBehaviorDefinitions, enemyClusters ?? sample.EnemyClusterDefinitions, collectibles ?? sample.CollectibleDefinitions, weapons ?? sample.WeaponDefinitions, specialWeapons ?? sample.SpecialWeaponDefinitions, squadronMateTypes ?? sample.SquadronMateTypeDefinitions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<GodotProjectDir>$(MSBuildProjectDirectory)\..\..\godot\</GodotProjectDir>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="6.0.4" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Using Include="Xunit" />
|
||||
<CompilerVisibleProperty Include="GodotProjectDir" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\godot\SideScrollerGame.Godot.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
9
tests/SideScrollerGame.Content.Tests/UnitTest1.cs
Normal file
9
tests/SideScrollerGame.Content.Tests/UnitTest1.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace SideScrollerGame.Content.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user