Add core content definitions

This commit is contained in:
2026-04-21 19:47:25 +02:00
parent d4b3c221b2
commit efcc1ba209
74 changed files with 1196 additions and 13 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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")

View 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..."

View File

@@ -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;
}

View 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
};
}
}

View File

@@ -0,0 +1 @@
uid://ced4h8gt2he8a

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content;
public sealed record DefinitionEntry(string Kind, string Id);

View File

@@ -0,0 +1 @@
uid://dlbg73ke5c56n

View File

@@ -0,0 +1,5 @@
#nullable enable
namespace SideScrollerGame.Content.Definitions;
public sealed record BehaviorEventDefinition(BehaviorEventKind Kind, double StartSeconds, double DurationSeconds, string? ReferenceId = null);

View File

@@ -0,0 +1 @@
uid://bfhvtjvxbesyr

View File

@@ -0,0 +1,12 @@
namespace SideScrollerGame.Content.Definitions;
public enum BehaviorEventKind
{
Wait,
MovePath,
RotatePath,
FireProjectile,
ChangeSpeed,
SpawnChild,
TriggerEffect
}

View File

@@ -0,0 +1 @@
uid://b3k6pfrvkik72

View File

@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace SideScrollerGame.Content.Definitions;
public sealed record BehaviorTrackDefinition(string Id, BehaviorTrackMode Mode, IReadOnlyList<BehaviorEventDefinition> Events);

View File

@@ -0,0 +1 @@
uid://b8sossiey58sy

View File

@@ -0,0 +1,7 @@
namespace SideScrollerGame.Content.Definitions;
public enum BehaviorTrackMode
{
Serial,
Parallel
}

View File

@@ -0,0 +1 @@
uid://cj7orw08m62rq

View File

@@ -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);

View File

@@ -0,0 +1 @@
uid://1sohi8c0b4p4

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record CameraPathPointDefinition(double TimeSeconds, double X, double Y, double Speed);

View File

@@ -0,0 +1 @@
uid://n5yggkiyqu3u

View File

@@ -0,0 +1,8 @@
namespace SideScrollerGame.Content.Definitions;
public enum ClusterEscapeRule
{
PreventReward,
AllowReward,
RespawnUntilDestroyed
}

View File

@@ -0,0 +1 @@
uid://bo12ujllh0vko

View File

@@ -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);

View File

@@ -0,0 +1 @@
uid://dojpxw2gdr0yo

View File

@@ -0,0 +1,12 @@
namespace SideScrollerGame.Content.Definitions;
public enum CollectibleKind
{
Points,
PrimaryWeapon,
SecondaryWeapon,
ClearScreen,
ShieldCharge,
SpecialAmmo,
SquadronMate
}

View File

@@ -0,0 +1 @@
uid://bbuwcsopsg4on

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record DifficultyDefinition(string Id, string DisplayName, DifficultyModifierSet Modifiers, int HeroStartingShieldCharges, int HeroRetryCount);

View File

@@ -0,0 +1 @@
uid://8lf15cyl337v

View File

@@ -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);

View File

@@ -0,0 +1 @@
uid://c3wiaioy3cj2l

View File

@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace SideScrollerGame.Content.Definitions;
public sealed record EnemyBehaviorDefinition(string Id, string DisplayName, IReadOnlyList<BehaviorTrackDefinition> Tracks);

View File

@@ -0,0 +1 @@
uid://b7qggd0xsyl8v

View File

@@ -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);

View File

@@ -0,0 +1 @@
uid://c5lqrpv5gc753

View 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);

View File

@@ -0,0 +1 @@
uid://cgsajqr5fvw5g

View File

@@ -0,0 +1,8 @@
namespace SideScrollerGame.Content.Definitions;
public enum LayerKind
{
Background,
Interactive,
Foreground
}

View File

@@ -0,0 +1 @@
uid://dp41dwk68qlin

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record LevelLayerDefinition(string Id, string DisplayName, LayerKind Kind, double ScrollFactor, bool Repeats);

View File

@@ -0,0 +1 @@
uid://dfikxuceq8ros

View 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);

View File

@@ -0,0 +1 @@
uid://d1gwpoe4obx5t

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record SpawnScheduleEntryDefinition(string EnemyTypeId, double SpawnTimeSeconds, double AnchorX, double AnchorY);

View File

@@ -0,0 +1 @@
uid://crrtalqhddqc3

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record SpecialWeaponDefinition(string Id, string DisplayName, SpecialWeaponKind Kind, int InitialAmmo, int AmmoPickupAmount, int Damage);

View File

@@ -0,0 +1 @@
uid://b7rncbmm228x6

View File

@@ -0,0 +1,9 @@
namespace SideScrollerGame.Content.Definitions;
public enum SpecialWeaponKind
{
Bomb,
Crawler,
Napalm,
BlackHole
}

View File

@@ -0,0 +1 @@
uid://cr8u8s2r2vlj5

View File

@@ -0,0 +1,10 @@
namespace SideScrollerGame.Content.Definitions;
public enum SquadronMateFormationKind
{
Hug,
Orbit,
LineFormation,
VFormation,
Follow
}

View File

@@ -0,0 +1 @@
uid://trmmvv4tfq0l

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record SquadronMateTypeDefinition(string Id, string DisplayName, SquadronMateFormationKind Formation, double Spacing, double MovementSmoothing);

View File

@@ -0,0 +1 @@
uid://b0n4orx2ffcu2

View 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);

View File

@@ -0,0 +1 @@
uid://br35s1xq4dspu

View File

@@ -0,0 +1,12 @@
namespace SideScrollerGame.Content.Definitions;
public enum WeaponKind
{
PrimaryBallistic,
PrimarySeeking,
PrimaryLaser,
PrimaryGrenadeCluster,
SecondaryVertical,
SecondaryDiagonal,
SecondaryBackward
}

View File

@@ -0,0 +1 @@
uid://bbc7rfx8wc610

View 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);
}
}

View File

@@ -0,0 +1 @@
uid://fchmkl772mpk

View File

@@ -0,0 +1,5 @@
#nullable enable
namespace SideScrollerGame.Content.Validation;
public sealed record ContentValidationMessage(ContentValidationSeverity Severity, string Code, string Message, string? DefinitionId = null);

View File

@@ -0,0 +1 @@
uid://8qcy6e8iuhae

View 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);
}

View File

@@ -0,0 +1 @@
uid://dbi1uj5y5xpa5

View File

@@ -0,0 +1,8 @@
namespace SideScrollerGame.Content.Validation;
public enum ContentValidationSeverity
{
Info,
Warning,
Error
}

View File

@@ -0,0 +1 @@
uid://c2kwhf3c7snb7

View 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));
}
}

View File

@@ -0,0 +1 @@
uid://chj207coyjf4j

View 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";
}

View File

@@ -0,0 +1 @@
uid://cfukg3hg4wwpx

View File

@@ -3,5 +3,6 @@ namespace SideScrollerGame.Debug;
public enum DebugBootMode
{
Menu,
Smoke
Smoke,
ContentBrowser
}

View File

@@ -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=";

View 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);
}
}

View File

@@ -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>

View File

@@ -0,0 +1,9 @@
namespace SideScrollerGame.Content.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}