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

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