Add debug foundation
This commit is contained in:
45
SLICE3.MD
45
SLICE3.MD
@@ -17,14 +17,14 @@ This slice does not implement the real hero, weapons, enemies, clusters, or miss
|
||||
- [x] (2026-04-21 17:43Z) Read repository rules, Windows rules, `PLANS.md`, `CODE.md`, `SLICE2.MD`, current debug scripts, current root scene, current content browser, and `godot/project.godot`.
|
||||
- [x] (2026-04-21 17:43Z) Verified that Slice 2 has committed content definitions, validation tests, content browser boot mode, and a clean worktree.
|
||||
- [x] (2026-04-21 17:43Z) Created this Slice 3 ExecPlan.
|
||||
- [ ] Implement pure debug runtime state and command service.
|
||||
- [ ] Add unit tests for debug commands and validation behavior.
|
||||
- [ ] Add Godot debug command node, upgraded overlay, clickable debug panel, and debug sandbox scene.
|
||||
- [ ] Add debug sandbox boot mode and input actions.
|
||||
- [ ] Add headless debug foundation smoke script.
|
||||
- [ ] Run formatting for touched C# files with `jb cleanupcode --build=False`.
|
||||
- [ ] Validate with .NET tests, .NET build, Godot solution build, debug sandbox smoke boot, content browser smoke boot, and existing smoke boot.
|
||||
- [ ] Commit the completed slice.
|
||||
- [x] (2026-04-21 19:18Z) Implemented pure debug runtime state and command service.
|
||||
- [x] (2026-04-21 19:18Z) Added unit tests for debug commands and validation behavior.
|
||||
- [x] (2026-04-21 19:18Z) Added Godot debug command node, upgraded overlay, clickable debug panel, and debug sandbox scene.
|
||||
- [x] (2026-04-21 19:18Z) Added debug sandbox boot mode and input actions.
|
||||
- [x] (2026-04-21 19:18Z) Added headless debug foundation smoke script.
|
||||
- [x] (2026-04-21 19:18Z) Ran formatting for touched C# files with `jb cleanupcode --build=False`.
|
||||
- [x] (2026-04-21 19:18Z) Validated with .NET tests, .NET build, Godot solution build, debug sandbox smoke boot, content browser smoke boot, and existing smoke boot.
|
||||
- [x] (2026-04-21 19:18Z) Commit the completed slice.
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
@@ -40,6 +40,9 @@ This slice does not implement the real hero, weapons, enemies, clusters, or miss
|
||||
- Observation: Content definitions and tests are available for difficulty ids, enemy ids, and mission timeline marker names.
|
||||
Evidence: `godot/scripts/content/samples/SampleContent.cs` creates `difficulty.easy`, `difficulty.normal`, `difficulty.hard`, `enemy.serial`, `enemy.parallel`, and mission markers such as `cluster.opening`.
|
||||
|
||||
- Observation: Godot generated script UID files for the new Godot-facing debug node scripts during the headless project scan.
|
||||
Evidence: `godot/scripts/debug/DebugCommandNode.cs.uid`, `godot/scripts/debug/DebugPanelController.cs.uid`, and `godot/scripts/debug/DebugSandboxController.cs.uid` appeared after `.\godot --headless --path godot --build-solutions --quit`.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- Decision: Implement debug command rules as plain C# first, with a Godot node wrapper for engine effects.
|
||||
@@ -60,7 +63,31 @@ This slice does not implement the real hero, weapons, enemies, clusters, or miss
|
||||
|
||||
## 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.
|
||||
Completed. Slice 3 added the pure debug command foundation under `godot/scripts/debug/commands/`, xUnit coverage in `tests/SideScrollerGame.Content.Tests/DebugCommandServiceTests.cs`, the Godot bridge node `DebugCommandNode`, the upgraded overlay, the clickable sandbox panel, the debug sandbox controller, the `DebugSandbox` scene, the `DebugSandbox` boot mode, and debug input actions in `godot/project.godot`.
|
||||
|
||||
Validation completed from `D:\Code\zfxaction26_1`:
|
||||
|
||||
dotnet test SideScrollerGame.sln
|
||||
Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22
|
||||
|
||||
dotnet build SideScrollerGame.sln
|
||||
Build succeeded.
|
||||
0 Warning(s)
|
||||
0 Error(s)
|
||||
|
||||
.\godot --headless --path godot --build-solutions --quit
|
||||
Exited successfully after project scan, .NET build, and script class registration.
|
||||
|
||||
.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333
|
||||
Debug foundation smoke succeeded
|
||||
|
||||
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
|
||||
Content validation succeeded
|
||||
|
||||
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
|
||||
Smoke scene loaded
|
||||
|
||||
Remaining risk: frame-step behavior is covered through the command service request counter and debug node bridge, but it has not been manually observed in the editor UI in this headless-only iteration.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
|
||||
@@ -77,3 +77,58 @@ quick_restart={
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":true,"meta_pressed":false,"pressed":false,"keycode":82,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_pause={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":80,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_frame_step={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":79,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_time_slower={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":44,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_time_faster={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":46,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_spawn_actor={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":49,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_jump_marker={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":50,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_toggle_invulnerability={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":51,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_toggle_infinite_special_ammo={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":52,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_toggle_no_enemy_fire={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":53,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_toggle_collision_shapes={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":54,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
debug_toggle_gameplay_bounds={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":55,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
[gd_scene load_steps=6 format=3 uid="uid://b1fxc23gkbqre"]
|
||||
[gd_scene load_steps=8 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="Script" path="res://scripts/debug/DebugCommandNode.cs" id="3_debug_command_node"]
|
||||
[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"]
|
||||
[ext_resource type="PackedScene" path="res://scenes/debug/DebugSandbox.tscn" id="6_debug_sandbox"]
|
||||
|
||||
[node name="GameRoot" type="Node"]
|
||||
script = ExtResource("1_game_root")
|
||||
MenuScene = ExtResource("3_menu")
|
||||
SmokeScene = ExtResource("4_smoke")
|
||||
ContentBrowserScene = ExtResource("5_content_browser")
|
||||
DebugSandboxScene = ExtResource("6_debug_sandbox")
|
||||
|
||||
[node name="DebugCommandNode" type="Node" parent="."]
|
||||
process_mode = 3
|
||||
script = ExtResource("3_debug_command_node")
|
||||
|
||||
[node name="DebugOverlay" type="CanvasLayer" parent="."]
|
||||
script = ExtResource("2_debug_overlay")
|
||||
|
||||
63
godot/scenes/debug/DebugSandbox.tscn
Normal file
63
godot/scenes/debug/DebugSandbox.tscn
Normal file
@@ -0,0 +1,63 @@
|
||||
[gd_scene load_steps=3 format=3 uid="uid://c6qvx8j8iodlo"]
|
||||
|
||||
[ext_resource type="Script" path="res://scripts/debug/DebugSandboxController.cs" id="1_debug_sandbox"]
|
||||
[ext_resource type="Script" path="res://scripts/debug/DebugPanelController.cs" id="2_debug_panel"]
|
||||
|
||||
[node name="DebugSandbox" type="Control"]
|
||||
process_mode = 3
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
script = ExtResource("1_debug_sandbox")
|
||||
|
||||
[node name="Main" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
offset_left = 16.0
|
||||
offset_top = 16.0
|
||||
offset_right = -16.0
|
||||
offset_bottom = -16.0
|
||||
grow_horizontal = 2
|
||||
grow_vertical = 2
|
||||
|
||||
[node name="DebugPanel" type="PanelContainer" parent="Main"]
|
||||
process_mode = 3
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(200, 0)
|
||||
script = ExtResource("2_debug_panel")
|
||||
|
||||
[node name="Playfield" type="PanelContainer" parent="Main"]
|
||||
layout_mode = 2
|
||||
size_flags_horizontal = 3
|
||||
|
||||
[node name="PlayfieldContent" type="VBoxContainer" parent="Main/Playfield"]
|
||||
layout_mode = 2
|
||||
|
||||
[node name="Title" type="Label" parent="Main/Playfield/PlayfieldContent"]
|
||||
layout_mode = 2
|
||||
text = "Debug Sandbox"
|
||||
|
||||
[node name="StateLabel" type="Label" parent="Main/Playfield/PlayfieldContent"]
|
||||
layout_mode = 2
|
||||
text = "State"
|
||||
|
||||
[node name="MarkerLabel" type="Label" parent="Main/Playfield/PlayfieldContent"]
|
||||
layout_mode = 2
|
||||
text = "Marker: none"
|
||||
|
||||
[node name="SpawnedActors" type="VBoxContainer" parent="Main/Playfield/PlayfieldContent"]
|
||||
layout_mode = 2
|
||||
size_flags_vertical = 3
|
||||
|
||||
[node name="LogScroll" type="ScrollContainer" parent="Main"]
|
||||
layout_mode = 2
|
||||
custom_minimum_size = Vector2(360, 0)
|
||||
|
||||
[node name="LogLabel" type="Label" parent="Main/LogScroll"]
|
||||
layout_mode = 2
|
||||
text = ""
|
||||
@@ -15,6 +15,9 @@ public partial class GameRoot : Node
|
||||
GD.Print($"Debug boot: {m_Settings.BootMode}");
|
||||
GD.Print($"Seed: {m_Settings.Seed}");
|
||||
|
||||
m_CommandNode = GetNodeOrNull<DebugCommandNode>("DebugCommandNode");
|
||||
m_CommandNode?.Initialize(m_Settings);
|
||||
|
||||
LoadBootScene(m_Settings.BootMode);
|
||||
}
|
||||
|
||||
@@ -24,6 +27,7 @@ public partial class GameRoot : Node
|
||||
{
|
||||
DebugBootMode.Smoke => SmokeScene,
|
||||
DebugBootMode.ContentBrowser => ContentBrowserScene,
|
||||
DebugBootMode.DebugSandbox => DebugSandboxScene,
|
||||
_ => MenuScene
|
||||
};
|
||||
|
||||
@@ -44,10 +48,17 @@ public partial class GameRoot : Node
|
||||
|
||||
DebugOverlay? overlay = GetNodeOrNull<DebugOverlay>("DebugOverlay");
|
||||
if (overlay is not null && m_Settings is not null)
|
||||
{
|
||||
if (m_CommandNode is not null)
|
||||
{
|
||||
overlay.Bind(m_CommandNode.Service, m_Settings, loadedSceneId);
|
||||
}
|
||||
else
|
||||
{
|
||||
overlay.SetStatus(m_Settings, loadedSceneId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Export]
|
||||
public PackedScene? MenuScene { get; set; }
|
||||
@@ -58,6 +69,10 @@ public partial class GameRoot : Node
|
||||
[Export]
|
||||
public PackedScene? ContentBrowserScene { get; set; }
|
||||
|
||||
[Export]
|
||||
public PackedScene? DebugSandboxScene { get; set; }
|
||||
|
||||
private Node? m_LoadedScene;
|
||||
private DebugSettings? m_Settings;
|
||||
private DebugCommandNode? m_CommandNode;
|
||||
}
|
||||
@@ -4,5 +4,6 @@ public enum DebugBootMode
|
||||
{
|
||||
Menu,
|
||||
Smoke,
|
||||
ContentBrowser
|
||||
ContentBrowser,
|
||||
DebugSandbox
|
||||
}
|
||||
110
godot/scripts/debug/DebugCommandNode.cs
Normal file
110
godot/scripts/debug/DebugCommandNode.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using Godot;
|
||||
using SideScrollerGame.Content.Samples;
|
||||
using SideScrollerGame.Debug.Commands;
|
||||
|
||||
namespace SideScrollerGame.Debug;
|
||||
|
||||
public partial class DebugCommandNode : Node
|
||||
{
|
||||
public override void _Ready()
|
||||
{
|
||||
ProcessMode = ProcessModeEnum.Always;
|
||||
_ = Service;
|
||||
ApplyEngineState();
|
||||
}
|
||||
|
||||
public override void _Process(double delta)
|
||||
{
|
||||
if (m_PendingFrameStep)
|
||||
{
|
||||
GetTree().Paused = State.IsPaused;
|
||||
m_PendingFrameStep = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (State.FrameStepRequestCount <= m_AppliedFrameStepCount)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_AppliedFrameStepCount = State.FrameStepRequestCount;
|
||||
GetTree().Paused = false;
|
||||
m_PendingFrameStep = true;
|
||||
}
|
||||
|
||||
public void Initialize(DebugSettings settings)
|
||||
{
|
||||
m_Service = new DebugCommandService(SampleContent.CreateRegistry(), settings.Seed);
|
||||
m_Service.StateChanged += _ => ApplyEngineState();
|
||||
GD.Seed((ulong)settings.Seed);
|
||||
ApplyEngineState();
|
||||
}
|
||||
|
||||
public DebugCommandResult Execute(DebugCommandId commandId, string? argument = null)
|
||||
{
|
||||
DebugCommandResult result = Service.Execute(commandId, argument);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
if (commandId == DebugCommandId.SetSeed && int.TryParse(argument, out int seed))
|
||||
{
|
||||
GD.Seed((ulong)seed);
|
||||
}
|
||||
else if (commandId == DebugCommandId.ReloadScene)
|
||||
{
|
||||
ReloadScene();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public DebugCommandService Service
|
||||
{
|
||||
get
|
||||
{
|
||||
if (m_Service is null)
|
||||
{
|
||||
m_Service = new DebugCommandService(SampleContent.CreateRegistry(), 1);
|
||||
m_Service.StateChanged += _ => ApplyEngineState();
|
||||
}
|
||||
|
||||
return m_Service;
|
||||
}
|
||||
}
|
||||
|
||||
private DebugRuntimeState State => Service.State;
|
||||
|
||||
private void ApplyEngineState()
|
||||
{
|
||||
Engine.TimeScale = State.TimeScale;
|
||||
if (!m_PendingFrameStep)
|
||||
{
|
||||
GetTree().Paused = State.IsPaused;
|
||||
}
|
||||
}
|
||||
|
||||
private void ReloadScene()
|
||||
{
|
||||
if (IsHeadlessSmokeScript())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GetTree().ReloadCurrentScene();
|
||||
}
|
||||
|
||||
private static bool IsHeadlessSmokeScript()
|
||||
{
|
||||
return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase) && OS.GetCmdlineUserArgs().Any(argument => argument.Equals("--debug-script=foundation-smoke", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private DebugCommandService? m_Service;
|
||||
private int m_AppliedFrameStepCount;
|
||||
private bool m_PendingFrameStep;
|
||||
}
|
||||
1
godot/scripts/debug/DebugCommandNode.cs.uid
Normal file
1
godot/scripts/debug/DebugCommandNode.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://e80qu87g6u7v
|
||||
@@ -1,6 +1,8 @@
|
||||
#nullable enable
|
||||
|
||||
using System.Globalization;
|
||||
using Godot;
|
||||
using SideScrollerGame.Debug.Commands;
|
||||
|
||||
namespace SideScrollerGame.Debug;
|
||||
|
||||
@@ -15,10 +17,34 @@ public partial class DebugOverlay : CanvasLayer
|
||||
|
||||
public void SetStatus(DebugSettings settings, string loadedSceneId)
|
||||
{
|
||||
m_Settings = settings;
|
||||
m_LoadedSceneId = loadedSceneId;
|
||||
Label label = EnsureLabel();
|
||||
label.Text = $"Debug boot: {settings.BootMode}\nSeed: {settings.Seed}\nScene: {loadedSceneId}\nDebug: {OS.IsDebugBuild()}";
|
||||
}
|
||||
|
||||
public void Bind(DebugCommandService service, DebugSettings settings, string loadedSceneId)
|
||||
{
|
||||
m_Service = service;
|
||||
m_Settings = settings;
|
||||
m_LoadedSceneId = loadedSceneId;
|
||||
service.StateChanged += _ => Refresh();
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
Label label = EnsureLabel();
|
||||
if (m_Service is null || m_Settings is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DebugRuntimeState state = m_Service.State;
|
||||
Visible = state.OverlayVisible;
|
||||
label.Text = $"Debug boot: {m_Settings.BootMode}\n" + $"Scene: {m_LoadedSceneId}\n" + $"Seed: {state.Seed}\n" + $"Difficulty: {state.ActiveDifficultyId}\n" + $"Paused: {state.IsPaused}\n" + $"Time scale: {state.TimeScale.ToString(CultureInfo.InvariantCulture)}\n" + $"Marker: {DisplayOrNone(state.CurrentMarkerId)}\n" + $"Spawned: {state.SpawnedActorCount} ({DisplayOrNone(state.LastSpawnedActorId)})\n" + $"Flags: invuln={state.Invulnerable}, ammo={state.InfiniteSpecialAmmo}, nofire={state.NoEnemyFire}\n" + $"Debug draw: collisions={state.ShowCollisionShapes}, bounds={state.ShowGameplayBounds}";
|
||||
}
|
||||
|
||||
private Label EnsureLabel()
|
||||
{
|
||||
if (m_StatusLabel is not null)
|
||||
@@ -39,5 +65,13 @@ public partial class DebugOverlay : CanvasLayer
|
||||
return m_StatusLabel;
|
||||
}
|
||||
|
||||
private Label? m_StatusLabel;
|
||||
private static string DisplayOrNone(string value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "none" : value;
|
||||
}
|
||||
|
||||
private Label? m_StatusLabel;
|
||||
private DebugCommandService? m_Service;
|
||||
private DebugSettings? m_Settings;
|
||||
private string m_LoadedSceneId = "none";
|
||||
}
|
||||
88
godot/scripts/debug/DebugPanelController.cs
Normal file
88
godot/scripts/debug/DebugPanelController.cs
Normal file
@@ -0,0 +1,88 @@
|
||||
#nullable enable
|
||||
|
||||
using Godot;
|
||||
using SideScrollerGame.Debug.Commands;
|
||||
|
||||
namespace SideScrollerGame.Debug;
|
||||
|
||||
public partial class DebugPanelController : PanelContainer
|
||||
{
|
||||
public override void _Ready()
|
||||
{
|
||||
ProcessMode = ProcessModeEnum.Always;
|
||||
EnsureLayout();
|
||||
TryBindFromRoot();
|
||||
}
|
||||
|
||||
public void Bind(DebugCommandNode commandNode)
|
||||
{
|
||||
m_CommandNode = commandNode;
|
||||
}
|
||||
|
||||
private void EnsureLayout()
|
||||
{
|
||||
if (m_Container is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_Container = new VBoxContainer { Name = "Controls" };
|
||||
AddChild(m_Container);
|
||||
|
||||
AddHeader("Time");
|
||||
AddButton("Pause", DebugCommandId.TogglePause);
|
||||
AddButton("Step", DebugCommandId.FrameStep);
|
||||
AddButton("0.25x", DebugCommandId.SetTimeScale, "0.25");
|
||||
AddButton("0.5x", DebugCommandId.SetTimeScale, "0.5");
|
||||
AddButton("1x", DebugCommandId.SetTimeScale, "1");
|
||||
AddButton("2x", DebugCommandId.SetTimeScale, "2");
|
||||
AddButton("4x", DebugCommandId.SetTimeScale, "4");
|
||||
|
||||
AddHeader("Content");
|
||||
AddButton("Easy", DebugCommandId.SetDifficulty, "difficulty.easy");
|
||||
AddButton("Normal", DebugCommandId.SetDifficulty, "difficulty.normal");
|
||||
AddButton("Hard", DebugCommandId.SetDifficulty, "difficulty.hard");
|
||||
AddButton("Spawn serial", DebugCommandId.SpawnActor, "enemy.serial");
|
||||
AddButton("Spawn parallel", DebugCommandId.SpawnActor, "enemy.parallel");
|
||||
AddButton("Intro", DebugCommandId.JumpToMarker, "intro");
|
||||
AddButton("Opening", DebugCommandId.JumpToMarker, "cluster.opening");
|
||||
|
||||
AddHeader("Flags");
|
||||
AddButton("Invulnerable", DebugCommandId.ToggleInvulnerability);
|
||||
AddButton("Infinite ammo", DebugCommandId.ToggleInfiniteSpecialAmmo);
|
||||
AddButton("No enemy fire", DebugCommandId.ToggleNoEnemyFire);
|
||||
AddButton("Collisions", DebugCommandId.ToggleCollisionShapes);
|
||||
AddButton("Bounds", DebugCommandId.ToggleGameplayBounds);
|
||||
|
||||
AddHeader("Scene");
|
||||
AddButton("Restart", DebugCommandId.RestartMission);
|
||||
AddButton("Reload", DebugCommandId.ReloadScene);
|
||||
}
|
||||
|
||||
private void TryBindFromRoot()
|
||||
{
|
||||
m_CommandNode ??= GetNodeOrNull<DebugCommandNode>("/root/GameRoot/DebugCommandNode");
|
||||
}
|
||||
|
||||
private void AddHeader(string text)
|
||||
{
|
||||
Label label = new() { Text = text };
|
||||
m_Container?.AddChild(label);
|
||||
}
|
||||
|
||||
private void AddButton(string text, DebugCommandId commandId, string? argument = null)
|
||||
{
|
||||
Button button = new() { Text = text };
|
||||
button.Pressed += () => Execute(commandId, argument);
|
||||
m_Container?.AddChild(button);
|
||||
}
|
||||
|
||||
private void Execute(DebugCommandId commandId, string? argument = null)
|
||||
{
|
||||
TryBindFromRoot();
|
||||
m_CommandNode?.Execute(commandId, argument);
|
||||
}
|
||||
|
||||
private DebugCommandNode? m_CommandNode;
|
||||
private VBoxContainer? m_Container;
|
||||
}
|
||||
1
godot/scripts/debug/DebugPanelController.cs.uid
Normal file
1
godot/scripts/debug/DebugPanelController.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cu534no72ttbm
|
||||
284
godot/scripts/debug/DebugSandboxController.cs
Normal file
284
godot/scripts/debug/DebugSandboxController.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Godot;
|
||||
using SideScrollerGame.Debug.Commands;
|
||||
|
||||
namespace SideScrollerGame.Debug;
|
||||
|
||||
public partial class DebugSandboxController : Control
|
||||
{
|
||||
public override void _Ready()
|
||||
{
|
||||
ProcessMode = ProcessModeEnum.Always;
|
||||
m_CommandNode = GetNodeOrNull<DebugCommandNode>("/root/GameRoot/DebugCommandNode");
|
||||
if (m_CommandNode is null)
|
||||
{
|
||||
GD.PushError("Debug sandbox needs /root/GameRoot/DebugCommandNode.");
|
||||
return;
|
||||
}
|
||||
|
||||
BindSceneNodes();
|
||||
BindCommandService();
|
||||
RefreshLabels();
|
||||
|
||||
if (ShouldRunFoundationSmoke())
|
||||
{
|
||||
_ = RunFoundationSmokeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public override void _UnhandledInput(InputEvent @event)
|
||||
{
|
||||
if (m_CommandNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (@event.IsActionPressed("debug_overlay"))
|
||||
{
|
||||
Execute(DebugCommandId.ToggleOverlay);
|
||||
}
|
||||
else if (@event.IsActionPressed("pause_game") || @event.IsActionPressed("debug_pause"))
|
||||
{
|
||||
Execute(DebugCommandId.TogglePause);
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_frame_step"))
|
||||
{
|
||||
Execute(DebugCommandId.FrameStep);
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_time_slower"))
|
||||
{
|
||||
Execute(DebugCommandId.SetTimeScale, NextTimeScale(-1));
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_time_faster"))
|
||||
{
|
||||
Execute(DebugCommandId.SetTimeScale, NextTimeScale(1));
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_spawn_actor"))
|
||||
{
|
||||
Execute(DebugCommandId.SpawnActor, "enemy.serial");
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_jump_marker"))
|
||||
{
|
||||
Execute(DebugCommandId.JumpToMarker, "cluster.opening");
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_toggle_invulnerability"))
|
||||
{
|
||||
Execute(DebugCommandId.ToggleInvulnerability);
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_toggle_infinite_special_ammo"))
|
||||
{
|
||||
Execute(DebugCommandId.ToggleInfiniteSpecialAmmo);
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_toggle_no_enemy_fire"))
|
||||
{
|
||||
Execute(DebugCommandId.ToggleNoEnemyFire);
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_toggle_collision_shapes"))
|
||||
{
|
||||
Execute(DebugCommandId.ToggleCollisionShapes);
|
||||
}
|
||||
else if (@event.IsActionPressed("debug_toggle_gameplay_bounds"))
|
||||
{
|
||||
Execute(DebugCommandId.ToggleGameplayBounds);
|
||||
}
|
||||
else if (@event.IsActionPressed("quick_restart"))
|
||||
{
|
||||
Execute(DebugCommandId.RestartMission);
|
||||
}
|
||||
}
|
||||
|
||||
private void BindSceneNodes()
|
||||
{
|
||||
m_MarkerLabel = GetNodeOrNull<Label>("Main/Playfield/PlayfieldContent/MarkerLabel");
|
||||
m_SpawnedActors = GetNodeOrNull<VBoxContainer>("Main/Playfield/PlayfieldContent/SpawnedActors");
|
||||
m_LogLabel = GetNodeOrNull<Label>("Main/LogScroll/LogLabel");
|
||||
m_StateLabel = GetNodeOrNull<Label>("Main/Playfield/PlayfieldContent/StateLabel");
|
||||
|
||||
DebugPanelController? panel = GetNodeOrNull<DebugPanelController>("Main/DebugPanel");
|
||||
if (panel is not null && m_CommandNode is not null)
|
||||
{
|
||||
panel.Bind(m_CommandNode);
|
||||
}
|
||||
}
|
||||
|
||||
private void BindCommandService()
|
||||
{
|
||||
if (m_CommandNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DebugCommandService service = m_CommandNode.Service;
|
||||
service.RegisterSpawnHandler(SpawnActor);
|
||||
service.RegisterTimelineJumpHandler(JumpToMarker);
|
||||
service.RegisterRestartHandler(RestartSandbox);
|
||||
service.RegisterReloadHandler(() => DebugCommandResult.Success(DebugCommandId.ReloadScene, "Reload scene requested"));
|
||||
service.CommandExecuted += HandleCommandExecuted;
|
||||
service.StateChanged += _ => RefreshLabels();
|
||||
}
|
||||
|
||||
private DebugCommandResult SpawnActor(string actorId)
|
||||
{
|
||||
Label label = new()
|
||||
{
|
||||
Text = $"{m_SpawnedActors?.GetChildCount() + 1 ?? 1}: {actorId}",
|
||||
CustomMinimumSize = new Vector2(180.0f, 24.0f)
|
||||
};
|
||||
m_SpawnedActors?.AddChild(label);
|
||||
return DebugCommandResult.Success(DebugCommandId.SpawnActor, $"Actor spawned: {actorId}", actorId);
|
||||
}
|
||||
|
||||
private DebugCommandResult JumpToMarker(string markerId)
|
||||
{
|
||||
if (m_MarkerLabel is not null)
|
||||
{
|
||||
m_MarkerLabel.Text = $"Marker: {markerId}";
|
||||
}
|
||||
|
||||
return DebugCommandResult.Success(DebugCommandId.JumpToMarker, $"Timeline marker: {markerId}", markerId);
|
||||
}
|
||||
|
||||
private DebugCommandResult RestartSandbox()
|
||||
{
|
||||
if (m_SpawnedActors is not null)
|
||||
{
|
||||
foreach (Node child in m_SpawnedActors.GetChildren())
|
||||
{
|
||||
child.QueueFree();
|
||||
}
|
||||
}
|
||||
|
||||
if (m_MarkerLabel is not null)
|
||||
{
|
||||
m_MarkerLabel.Text = "Marker: none";
|
||||
}
|
||||
|
||||
return DebugCommandResult.Success(DebugCommandId.RestartMission, "Sandbox restarted");
|
||||
}
|
||||
|
||||
private void HandleCommandExecuted(DebugCommandResult result)
|
||||
{
|
||||
string suffix = string.IsNullOrWhiteSpace(result.Argument) ? string.Empty : $" {result.Argument}";
|
||||
AppendLog($"Command executed: {result.CommandId}{suffix}");
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
AppendLog($"Command failed: {result.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.CommandId is DebugCommandId.SpawnActor or DebugCommandId.JumpToMarker)
|
||||
{
|
||||
AppendLog(result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshLabels()
|
||||
{
|
||||
if (m_CommandNode is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DebugRuntimeState state = m_CommandNode.Service.State;
|
||||
if (m_StateLabel is not null)
|
||||
{
|
||||
m_StateLabel.Text = $"Difficulty: {state.ActiveDifficultyId} | Seed: {state.Seed} | Paused: {state.IsPaused} | Time: {state.TimeScale.ToString(CultureInfo.InvariantCulture)} | Spawns: {state.SpawnedActorCount}";
|
||||
}
|
||||
|
||||
if (m_MarkerLabel is not null && string.IsNullOrWhiteSpace(state.CurrentMarkerId))
|
||||
{
|
||||
m_MarkerLabel.Text = "Marker: none";
|
||||
}
|
||||
}
|
||||
|
||||
private void Execute(DebugCommandId commandId, string? argument = null)
|
||||
{
|
||||
m_CommandNode?.Execute(commandId, argument);
|
||||
}
|
||||
|
||||
private string NextTimeScale(int direction)
|
||||
{
|
||||
if (m_CommandNode is null)
|
||||
{
|
||||
return "1";
|
||||
}
|
||||
|
||||
double current = m_CommandNode.Service.State.TimeScale;
|
||||
int index = Array.IndexOf(s_TimeScales, current);
|
||||
if (index < 0)
|
||||
{
|
||||
index = Array.IndexOf(s_TimeScales, 1.0);
|
||||
}
|
||||
|
||||
int nextIndex = Math.Clamp(index + direction, 0, s_TimeScales.Length - 1);
|
||||
return s_TimeScales[nextIndex].ToString(CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private async Task RunFoundationSmokeAsync()
|
||||
{
|
||||
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||
AppendLog("Debug foundation smoke loaded");
|
||||
|
||||
bool succeeded = ExecuteAndRequire(DebugCommandId.Pause) && ExecuteAndRequire(DebugCommandId.SetTimeScale, "0.5") && ExecuteAndRequire(DebugCommandId.SetDifficulty, "difficulty.hard") && ExecuteAndRequire(DebugCommandId.SetSeed, m_CommandNode?.Service.State.Seed.ToString(CultureInfo.InvariantCulture)) && ExecuteAndRequire(DebugCommandId.SpawnActor, "enemy.serial") && ExecuteAndRequire(DebugCommandId.JumpToMarker, "cluster.opening") && ExecuteAndRequire(DebugCommandId.ToggleInvulnerability) && ExecuteAndRequire(DebugCommandId.ToggleInfiniteSpecialAmmo) && ExecuteAndRequire(DebugCommandId.ToggleNoEnemyFire) && ExecuteAndRequire(DebugCommandId.ToggleCollisionShapes) && ExecuteAndRequire(DebugCommandId.ToggleGameplayBounds) && ExecuteAndRequire(DebugCommandId.RestartMission) && ExecuteAndRequire(DebugCommandId.SpawnActor, "enemy.parallel") && VerifyFoundationSmokeState();
|
||||
|
||||
AppendLog(succeeded ? "Debug foundation smoke succeeded" : "Debug foundation smoke failed");
|
||||
GetTree().Quit(succeeded ? 0 : 1);
|
||||
}
|
||||
|
||||
private bool ExecuteAndRequire(DebugCommandId commandId, string? argument = null)
|
||||
{
|
||||
if (m_CommandNode is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugCommandResult result = m_CommandNode.Execute(commandId, argument);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
AppendLog(result.Message);
|
||||
return false;
|
||||
}
|
||||
|
||||
private bool VerifyFoundationSmokeState()
|
||||
{
|
||||
if (m_CommandNode is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugRuntimeState state = m_CommandNode.Service.State;
|
||||
return state.IsPaused && Math.Abs(state.TimeScale - 0.5) < 0.001 && state.ActiveDifficultyId == "difficulty.hard" && state.LastSpawnedActorId == "enemy.parallel" && state.SpawnedActorCount == 1 && state.RestartMissionRequestCount == 1 && state.Invulnerable && state.InfiniteSpecialAmmo && state.NoEnemyFire && state.ShowCollisionShapes && state.ShowGameplayBounds;
|
||||
}
|
||||
|
||||
private void AppendLog(string message)
|
||||
{
|
||||
GD.Print(message);
|
||||
if (m_LogLabel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
m_LogLabel.Text = string.IsNullOrWhiteSpace(m_LogLabel.Text) ? message : $"{m_LogLabel.Text}\n{message}";
|
||||
}
|
||||
|
||||
private static bool ShouldRunFoundationSmoke()
|
||||
{
|
||||
return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase) && OS.GetCmdlineUserArgs().Any(argument => argument.Equals("--debug-script=foundation-smoke", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static readonly double[] s_TimeScales = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
|
||||
private DebugCommandNode? m_CommandNode;
|
||||
private Label? m_MarkerLabel;
|
||||
private VBoxContainer? m_SpawnedActors;
|
||||
private Label? m_LogLabel;
|
||||
private Label? m_StateLabel;
|
||||
}
|
||||
1
godot/scripts/debug/DebugSandboxController.cs.uid
Normal file
1
godot/scripts/debug/DebugSandboxController.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dcfnx61rcgnr6
|
||||
24
godot/scripts/debug/commands/DebugCommandId.cs
Normal file
24
godot/scripts/debug/commands/DebugCommandId.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
#nullable enable
|
||||
|
||||
namespace SideScrollerGame.Debug.Commands;
|
||||
|
||||
public enum DebugCommandId
|
||||
{
|
||||
ToggleOverlay,
|
||||
Pause,
|
||||
Resume,
|
||||
TogglePause,
|
||||
FrameStep,
|
||||
SetTimeScale,
|
||||
ReloadScene,
|
||||
RestartMission,
|
||||
SetDifficulty,
|
||||
SetSeed,
|
||||
SpawnActor,
|
||||
JumpToMarker,
|
||||
ToggleCollisionShapes,
|
||||
ToggleGameplayBounds,
|
||||
ToggleInvulnerability,
|
||||
ToggleInfiniteSpecialAmmo,
|
||||
ToggleNoEnemyFire
|
||||
}
|
||||
1
godot/scripts/debug/commands/DebugCommandId.cs.uid
Normal file
1
godot/scripts/debug/commands/DebugCommandId.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ctqlux8vh57w8
|
||||
16
godot/scripts/debug/commands/DebugCommandResult.cs
Normal file
16
godot/scripts/debug/commands/DebugCommandResult.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
#nullable enable
|
||||
|
||||
namespace SideScrollerGame.Debug.Commands;
|
||||
|
||||
public sealed record DebugCommandResult(DebugCommandId CommandId, bool Succeeded, string Message, string? Argument = null)
|
||||
{
|
||||
public static DebugCommandResult Success(DebugCommandId commandId, string message, string? argument = null)
|
||||
{
|
||||
return new DebugCommandResult(commandId, true, message, argument);
|
||||
}
|
||||
|
||||
public static DebugCommandResult Failure(DebugCommandId commandId, string message, string? argument = null)
|
||||
{
|
||||
return new DebugCommandResult(commandId, false, message, argument);
|
||||
}
|
||||
}
|
||||
1
godot/scripts/debug/commands/DebugCommandResult.cs.uid
Normal file
1
godot/scripts/debug/commands/DebugCommandResult.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d1vtu2ro2libp
|
||||
283
godot/scripts/debug/commands/DebugCommandService.cs
Normal file
283
godot/scripts/debug/commands/DebugCommandService.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using SideScrollerGame.Content;
|
||||
|
||||
namespace SideScrollerGame.Debug.Commands;
|
||||
|
||||
public sealed class DebugCommandService
|
||||
{
|
||||
public DebugCommandService(ContentRegistry registry, int seed)
|
||||
{
|
||||
m_Registry = registry;
|
||||
State = new DebugRuntimeState(FindDefaultDifficultyId(registry), seed);
|
||||
}
|
||||
|
||||
public DebugCommandResult Execute(DebugCommandId commandId, string? argument = null)
|
||||
{
|
||||
DebugCommandResult result = commandId switch
|
||||
{
|
||||
DebugCommandId.ToggleOverlay => ToggleOverlay(),
|
||||
DebugCommandId.Pause => SetPaused(commandId, true),
|
||||
DebugCommandId.Resume => SetPaused(commandId, false),
|
||||
DebugCommandId.TogglePause => SetPaused(commandId, !State.IsPaused),
|
||||
DebugCommandId.FrameStep => FrameStep(),
|
||||
DebugCommandId.SetTimeScale => SetTimeScale(argument),
|
||||
DebugCommandId.ReloadScene => ReloadScene(),
|
||||
DebugCommandId.RestartMission => RestartMission(),
|
||||
DebugCommandId.SetDifficulty => SetDifficulty(argument),
|
||||
DebugCommandId.SetSeed => SetSeed(argument),
|
||||
DebugCommandId.SpawnActor => SpawnActor(argument),
|
||||
DebugCommandId.JumpToMarker => JumpToMarker(argument),
|
||||
DebugCommandId.ToggleCollisionShapes => ToggleFlag(commandId, nameof(State.ShowCollisionShapes)),
|
||||
DebugCommandId.ToggleGameplayBounds => ToggleFlag(commandId, nameof(State.ShowGameplayBounds)),
|
||||
DebugCommandId.ToggleInvulnerability => ToggleFlag(commandId, nameof(State.Invulnerable)),
|
||||
DebugCommandId.ToggleInfiniteSpecialAmmo => ToggleFlag(commandId, nameof(State.InfiniteSpecialAmmo)),
|
||||
DebugCommandId.ToggleNoEnemyFire => ToggleFlag(commandId, nameof(State.NoEnemyFire)),
|
||||
_ => DebugCommandResult.Failure(commandId, $"Unsupported debug command '{commandId}'.", argument)
|
||||
};
|
||||
|
||||
CommandExecuted?.Invoke(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
public void RegisterSpawnHandler(Func<string, DebugCommandResult> handler)
|
||||
{
|
||||
m_SpawnHandler = handler;
|
||||
}
|
||||
|
||||
public void RegisterTimelineJumpHandler(Func<string, DebugCommandResult> handler)
|
||||
{
|
||||
m_TimelineJumpHandler = handler;
|
||||
}
|
||||
|
||||
public void RegisterReloadHandler(Func<DebugCommandResult> handler)
|
||||
{
|
||||
m_ReloadHandler = handler;
|
||||
}
|
||||
|
||||
public void RegisterRestartHandler(Func<DebugCommandResult> handler)
|
||||
{
|
||||
m_RestartHandler = handler;
|
||||
}
|
||||
|
||||
public DebugRuntimeState State { get; }
|
||||
|
||||
public event Action<DebugRuntimeState>? StateChanged;
|
||||
|
||||
public event Action<DebugCommandResult>? CommandExecuted;
|
||||
|
||||
private DebugCommandResult ToggleOverlay()
|
||||
{
|
||||
State.OverlayVisible = !State.OverlayVisible;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(DebugCommandId.ToggleOverlay, $"Overlay visible: {State.OverlayVisible}");
|
||||
}
|
||||
|
||||
private DebugCommandResult SetPaused(DebugCommandId commandId, bool isPaused)
|
||||
{
|
||||
State.IsPaused = isPaused;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(commandId, State.IsPaused ? "Paused" : "Resumed");
|
||||
}
|
||||
|
||||
private DebugCommandResult FrameStep()
|
||||
{
|
||||
State.IsPaused = true;
|
||||
State.FrameStepRequestCount++;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(DebugCommandId.FrameStep, "Frame step requested");
|
||||
}
|
||||
|
||||
private DebugCommandResult SetTimeScale(string? argument)
|
||||
{
|
||||
if (!double.TryParse(argument, NumberStyles.Float, CultureInfo.InvariantCulture, out double timeScale) || !s_SupportedTimeScales.Contains(timeScale))
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.SetTimeScale, $"Unsupported time scale '{argument}'.", argument);
|
||||
}
|
||||
|
||||
State.TimeScale = timeScale;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(DebugCommandId.SetTimeScale, $"Time scale set to {timeScale.ToString(CultureInfo.InvariantCulture)}", argument);
|
||||
}
|
||||
|
||||
private DebugCommandResult ReloadScene()
|
||||
{
|
||||
State.ReloadSceneRequestCount++;
|
||||
DebugCommandResult result = m_ReloadHandler?.Invoke() ?? DebugCommandResult.Success(DebugCommandId.ReloadScene, "Reload scene requested");
|
||||
NotifyStateChanged();
|
||||
return result.Succeeded ? DebugCommandResult.Success(DebugCommandId.ReloadScene, result.Message) : result;
|
||||
}
|
||||
|
||||
private DebugCommandResult RestartMission()
|
||||
{
|
||||
State.RestartMissionRequestCount++;
|
||||
State.CurrentMarkerId = string.Empty;
|
||||
State.LastSpawnedActorId = string.Empty;
|
||||
State.SpawnedActorCount = 0;
|
||||
DebugCommandResult result = m_RestartHandler?.Invoke() ?? DebugCommandResult.Success(DebugCommandId.RestartMission, "Restart mission requested");
|
||||
NotifyStateChanged();
|
||||
return result.Succeeded ? DebugCommandResult.Success(DebugCommandId.RestartMission, result.Message) : result;
|
||||
}
|
||||
|
||||
private DebugCommandResult SetDifficulty(string? argument)
|
||||
{
|
||||
string difficultyId = RequireArgument(argument);
|
||||
if (difficultyId.Length == 0)
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.SetDifficulty, "Missing difficulty id.", argument);
|
||||
}
|
||||
|
||||
if (!m_Registry.Difficulties.ContainsKey(difficultyId))
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.SetDifficulty, $"Unknown difficulty '{difficultyId}'.", difficultyId);
|
||||
}
|
||||
|
||||
State.ActiveDifficultyId = difficultyId;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(DebugCommandId.SetDifficulty, $"Difficulty set to {difficultyId}", difficultyId);
|
||||
}
|
||||
|
||||
private DebugCommandResult SetSeed(string? argument)
|
||||
{
|
||||
if (!int.TryParse(argument, NumberStyles.Integer, CultureInfo.InvariantCulture, out int seed))
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.SetSeed, $"Invalid seed '{argument}'.", argument);
|
||||
}
|
||||
|
||||
State.Seed = seed;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(DebugCommandId.SetSeed, $"Seed set to {seed}", argument);
|
||||
}
|
||||
|
||||
private DebugCommandResult SpawnActor(string? argument)
|
||||
{
|
||||
string actorId = RequireArgument(argument);
|
||||
if (actorId.Length == 0)
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.SpawnActor, "Missing actor id.", argument);
|
||||
}
|
||||
|
||||
if (!m_Registry.EnemyTypes.ContainsKey(actorId))
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.SpawnActor, $"Unknown actor '{actorId}'.", actorId);
|
||||
}
|
||||
|
||||
DebugCommandResult result = m_SpawnHandler?.Invoke(actorId) ?? DebugCommandResult.Success(DebugCommandId.SpawnActor, $"Spawn actor requested: {actorId}", actorId);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
State.LastSpawnedActorId = actorId;
|
||||
State.SpawnedActorCount++;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(DebugCommandId.SpawnActor, result.Message, actorId);
|
||||
}
|
||||
|
||||
private DebugCommandResult JumpToMarker(string? argument)
|
||||
{
|
||||
string markerId = RequireArgument(argument);
|
||||
if (markerId.Length == 0)
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.JumpToMarker, "Missing marker id.", argument);
|
||||
}
|
||||
|
||||
if (!HasMissionMarker(markerId))
|
||||
{
|
||||
return DebugCommandResult.Failure(DebugCommandId.JumpToMarker, $"Unknown marker '{markerId}'.", markerId);
|
||||
}
|
||||
|
||||
DebugCommandResult result = m_TimelineJumpHandler?.Invoke(markerId) ?? DebugCommandResult.Success(DebugCommandId.JumpToMarker, $"Jump marker requested: {markerId}", markerId);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
State.CurrentMarkerId = markerId;
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(DebugCommandId.JumpToMarker, result.Message, markerId);
|
||||
}
|
||||
|
||||
private DebugCommandResult ToggleFlag(DebugCommandId commandId, string propertyName)
|
||||
{
|
||||
string message = propertyName switch
|
||||
{
|
||||
nameof(State.ShowCollisionShapes) => ToggleShowCollisionShapes(),
|
||||
nameof(State.ShowGameplayBounds) => ToggleShowGameplayBounds(),
|
||||
nameof(State.Invulnerable) => ToggleInvulnerable(),
|
||||
nameof(State.InfiniteSpecialAmmo) => ToggleInfiniteSpecialAmmo(),
|
||||
nameof(State.NoEnemyFire) => ToggleNoEnemyFire(),
|
||||
_ => $"Unsupported flag '{propertyName}'."
|
||||
};
|
||||
|
||||
NotifyStateChanged();
|
||||
return DebugCommandResult.Success(commandId, message);
|
||||
}
|
||||
|
||||
private string ToggleShowCollisionShapes()
|
||||
{
|
||||
State.ShowCollisionShapes = !State.ShowCollisionShapes;
|
||||
return $"Collision shapes: {State.ShowCollisionShapes}";
|
||||
}
|
||||
|
||||
private string ToggleShowGameplayBounds()
|
||||
{
|
||||
State.ShowGameplayBounds = !State.ShowGameplayBounds;
|
||||
return $"Gameplay bounds: {State.ShowGameplayBounds}";
|
||||
}
|
||||
|
||||
private string ToggleInvulnerable()
|
||||
{
|
||||
State.Invulnerable = !State.Invulnerable;
|
||||
return $"Invulnerable: {State.Invulnerable}";
|
||||
}
|
||||
|
||||
private string ToggleInfiniteSpecialAmmo()
|
||||
{
|
||||
State.InfiniteSpecialAmmo = !State.InfiniteSpecialAmmo;
|
||||
return $"Infinite special ammo: {State.InfiniteSpecialAmmo}";
|
||||
}
|
||||
|
||||
private string ToggleNoEnemyFire()
|
||||
{
|
||||
State.NoEnemyFire = !State.NoEnemyFire;
|
||||
return $"No enemy fire: {State.NoEnemyFire}";
|
||||
}
|
||||
|
||||
private bool HasMissionMarker(string markerId)
|
||||
{
|
||||
return m_Registry.MissionDefinitions.Any(mission => mission.TimelineMarkers.Contains(markerId));
|
||||
}
|
||||
|
||||
private void NotifyStateChanged()
|
||||
{
|
||||
StateChanged?.Invoke(State);
|
||||
}
|
||||
|
||||
private static string RequireArgument(string? argument)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(argument) ? string.Empty : argument.Trim();
|
||||
}
|
||||
|
||||
private static string FindDefaultDifficultyId(ContentRegistry registry)
|
||||
{
|
||||
if (registry.TryGetMission("mission.test", out var mission) && mission is not null && registry.Difficulties.ContainsKey(mission.DefaultDifficultyId))
|
||||
{
|
||||
return mission.DefaultDifficultyId;
|
||||
}
|
||||
|
||||
return registry.DifficultyDefinitions.FirstOrDefault()?.Id ?? string.Empty;
|
||||
}
|
||||
|
||||
private static readonly HashSet<double> s_SupportedTimeScales = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||
|
||||
private readonly ContentRegistry m_Registry;
|
||||
private Func<string, DebugCommandResult>? m_SpawnHandler;
|
||||
private Func<string, DebugCommandResult>? m_TimelineJumpHandler;
|
||||
private Func<DebugCommandResult>? m_ReloadHandler;
|
||||
private Func<DebugCommandResult>? m_RestartHandler;
|
||||
}
|
||||
1
godot/scripts/debug/commands/DebugCommandService.cs.uid
Normal file
1
godot/scripts/debug/commands/DebugCommandService.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bqt7rjgmg268u
|
||||
44
godot/scripts/debug/commands/DebugRuntimeState.cs
Normal file
44
godot/scripts/debug/commands/DebugRuntimeState.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
#nullable enable
|
||||
|
||||
namespace SideScrollerGame.Debug.Commands;
|
||||
|
||||
public sealed class DebugRuntimeState
|
||||
{
|
||||
public DebugRuntimeState(string activeDifficultyId, int seed)
|
||||
{
|
||||
ActiveDifficultyId = activeDifficultyId;
|
||||
Seed = seed;
|
||||
}
|
||||
|
||||
public bool OverlayVisible { get; internal set; } = true;
|
||||
|
||||
public bool IsPaused { get; internal set; }
|
||||
|
||||
public double TimeScale { get; internal set; } = 1.0;
|
||||
|
||||
public string ActiveDifficultyId { get; internal set; }
|
||||
|
||||
public int Seed { get; internal set; }
|
||||
|
||||
public string CurrentMarkerId { get; internal set; } = string.Empty;
|
||||
|
||||
public string LastSpawnedActorId { get; internal set; } = string.Empty;
|
||||
|
||||
public int SpawnedActorCount { get; internal set; }
|
||||
|
||||
public bool ShowCollisionShapes { get; internal set; }
|
||||
|
||||
public bool ShowGameplayBounds { get; internal set; }
|
||||
|
||||
public bool Invulnerable { get; internal set; }
|
||||
|
||||
public bool InfiniteSpecialAmmo { get; internal set; }
|
||||
|
||||
public bool NoEnemyFire { get; internal set; }
|
||||
|
||||
public int ReloadSceneRequestCount { get; internal set; }
|
||||
|
||||
public int RestartMissionRequestCount { get; internal set; }
|
||||
|
||||
public int FrameStepRequestCount { get; internal set; }
|
||||
}
|
||||
1
godot/scripts/debug/commands/DebugRuntimeState.cs.uid
Normal file
1
godot/scripts/debug/commands/DebugRuntimeState.cs.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://iaejn5w2r8q1
|
||||
176
tests/SideScrollerGame.Content.Tests/DebugCommandServiceTests.cs
Normal file
176
tests/SideScrollerGame.Content.Tests/DebugCommandServiceTests.cs
Normal file
@@ -0,0 +1,176 @@
|
||||
#nullable enable
|
||||
|
||||
using SideScrollerGame.Content;
|
||||
using SideScrollerGame.Content.Samples;
|
||||
using SideScrollerGame.Debug.Commands;
|
||||
|
||||
namespace SideScrollerGame.Content.Tests;
|
||||
|
||||
public class DebugCommandServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public void PauseResumeAndTogglePause_UpdateState()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
|
||||
service.Execute(DebugCommandId.Pause);
|
||||
Assert.True(service.State.IsPaused);
|
||||
|
||||
service.Execute(DebugCommandId.Resume);
|
||||
Assert.False(service.State.IsPaused);
|
||||
|
||||
service.Execute(DebugCommandId.TogglePause);
|
||||
Assert.True(service.State.IsPaused);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0.25", 0.25)]
|
||||
[InlineData("0.5", 0.5)]
|
||||
[InlineData("1", 1.0)]
|
||||
[InlineData("2", 2.0)]
|
||||
[InlineData("4", 4.0)]
|
||||
public void SetTimeScale_AcceptsSupportedValues(string argument, double expected)
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
|
||||
DebugCommandResult result = service.Execute(DebugCommandId.SetTimeScale, argument);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(expected, service.State.TimeScale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetTimeScale_RejectsUnsupportedValue()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
|
||||
DebugCommandResult result = service.Execute(DebugCommandId.SetTimeScale, "3");
|
||||
|
||||
Assert.False(result.Succeeded);
|
||||
Assert.Equal(1.0, service.State.TimeScale);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetDifficulty_ValidatesIds()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
|
||||
DebugCommandResult validResult = service.Execute(DebugCommandId.SetDifficulty, "difficulty.hard");
|
||||
DebugCommandResult invalidResult = service.Execute(DebugCommandId.SetDifficulty, "difficulty.missing");
|
||||
|
||||
Assert.True(validResult.Succeeded);
|
||||
Assert.Equal("difficulty.hard", service.State.ActiveDifficultyId);
|
||||
Assert.False(invalidResult.Succeeded);
|
||||
Assert.Equal("difficulty.hard", service.State.ActiveDifficultyId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SetSeed_UpdatesState()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
|
||||
DebugCommandResult result = service.Execute(DebugCommandId.SetSeed, "333");
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(333, service.State.Seed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToggleFlags_UpdateState()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
|
||||
service.Execute(DebugCommandId.ToggleCollisionShapes);
|
||||
service.Execute(DebugCommandId.ToggleGameplayBounds);
|
||||
service.Execute(DebugCommandId.ToggleInvulnerability);
|
||||
service.Execute(DebugCommandId.ToggleInfiniteSpecialAmmo);
|
||||
service.Execute(DebugCommandId.ToggleNoEnemyFire);
|
||||
|
||||
Assert.True(service.State.ShowCollisionShapes);
|
||||
Assert.True(service.State.ShowGameplayBounds);
|
||||
Assert.True(service.State.Invulnerable);
|
||||
Assert.True(service.State.InfiniteSpecialAmmo);
|
||||
Assert.True(service.State.NoEnemyFire);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SpawnActor_ValidatesActorAndRunsHandler()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
string spawnedActorId = string.Empty;
|
||||
service.RegisterSpawnHandler(actorId =>
|
||||
{
|
||||
spawnedActorId = actorId;
|
||||
return DebugCommandResult.Success(DebugCommandId.SpawnActor, $"Spawned {actorId}", actorId);
|
||||
});
|
||||
|
||||
DebugCommandResult validResult = service.Execute(DebugCommandId.SpawnActor, "enemy.serial");
|
||||
DebugCommandResult invalidResult = service.Execute(DebugCommandId.SpawnActor, "enemy.missing");
|
||||
|
||||
Assert.True(validResult.Succeeded);
|
||||
Assert.Equal("enemy.serial", spawnedActorId);
|
||||
Assert.Equal("enemy.serial", service.State.LastSpawnedActorId);
|
||||
Assert.Equal(1, service.State.SpawnedActorCount);
|
||||
Assert.False(invalidResult.Succeeded);
|
||||
Assert.Equal(1, service.State.SpawnedActorCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JumpToMarker_ValidatesMarkerAndRunsHandler()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
string jumpedMarkerId = string.Empty;
|
||||
service.RegisterTimelineJumpHandler(markerId =>
|
||||
{
|
||||
jumpedMarkerId = markerId;
|
||||
return DebugCommandResult.Success(DebugCommandId.JumpToMarker, $"Jumped {markerId}", markerId);
|
||||
});
|
||||
|
||||
DebugCommandResult validResult = service.Execute(DebugCommandId.JumpToMarker, "cluster.opening");
|
||||
DebugCommandResult invalidResult = service.Execute(DebugCommandId.JumpToMarker, "cluster.missing");
|
||||
|
||||
Assert.True(validResult.Succeeded);
|
||||
Assert.Equal("cluster.opening", jumpedMarkerId);
|
||||
Assert.Equal("cluster.opening", service.State.CurrentMarkerId);
|
||||
Assert.False(invalidResult.Succeeded);
|
||||
Assert.Equal("cluster.opening", service.State.CurrentMarkerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartMission_IncrementsRequestCountAndClearsRuntimeTargets()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
service.Execute(DebugCommandId.SpawnActor, "enemy.serial");
|
||||
service.Execute(DebugCommandId.JumpToMarker, "cluster.opening");
|
||||
|
||||
DebugCommandResult result = service.Execute(DebugCommandId.RestartMission);
|
||||
|
||||
Assert.True(result.Succeeded);
|
||||
Assert.Equal(1, service.State.RestartMissionRequestCount);
|
||||
Assert.Equal(0, service.State.SpawnedActorCount);
|
||||
Assert.Equal(string.Empty, service.State.LastSpawnedActorId);
|
||||
Assert.Equal(string.Empty, service.State.CurrentMarkerId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CommandEvents_FireForExecutedCommands()
|
||||
{
|
||||
DebugCommandService service = CreateService();
|
||||
int stateChangedCount = 0;
|
||||
int commandExecutedCount = 0;
|
||||
service.StateChanged += _ => stateChangedCount++;
|
||||
service.CommandExecuted += _ => commandExecutedCount++;
|
||||
|
||||
service.Execute(DebugCommandId.Pause);
|
||||
service.Execute(DebugCommandId.SetDifficulty, "difficulty.missing");
|
||||
|
||||
Assert.Equal(1, stateChangedCount);
|
||||
Assert.Equal(2, commandExecutedCount);
|
||||
}
|
||||
|
||||
private static DebugCommandService CreateService()
|
||||
{
|
||||
ContentRegistry registry = SampleContent.CreateRegistry();
|
||||
return new DebugCommandService(registry, 123);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user