Add hero runtime
This commit is contained in:
50
SLICE4.md
50
SLICE4.md
@@ -17,12 +17,13 @@ This slice does not implement weapon firing, collectibles as physical pickup act
|
|||||||
- [x] (2026-04-21 20:22Z) Read repository rules, `PLANS.md`, `DESIGN.md`, `CODE.md`, `SLICE1.MD`, `SLICE2.MD`, `SLICE3.MD`, current debug command files, current sample content, current root boot wiring, and current project file list.
|
- [x] (2026-04-21 20:22Z) Read repository rules, `PLANS.md`, `DESIGN.md`, `CODE.md`, `SLICE1.MD`, `SLICE2.MD`, `SLICE3.MD`, current debug command files, current sample content, current root boot wiring, and current project file list.
|
||||||
- [x] (2026-04-21 20:22Z) Verified that Slice 3 has committed a debug command foundation, a debug sandbox boot mode, content validation tests, and a clean worktree.
|
- [x] (2026-04-21 20:22Z) Verified that Slice 3 has committed a debug command foundation, a debug sandbox boot mode, content validation tests, and a clean worktree.
|
||||||
- [x] (2026-04-21 20:22Z) Created this Slice 4 ExecPlan.
|
- [x] (2026-04-21 20:22Z) Created this Slice 4 ExecPlan.
|
||||||
- [ ] Implement pure hero runtime state and rules.
|
- [x] (2026-04-21 20:53Z) Implemented pure hero runtime state and rules.
|
||||||
- [ ] Add unit tests for hero movement-independent rules.
|
- [x] (2026-04-21 20:53Z) Added unit tests for hero movement-independent rules.
|
||||||
- [ ] Add Godot hero actor, hero HUD, and hero sandbox.
|
- [x] (2026-04-21 20:53Z) Added Godot hero actor, hero HUD, and hero sandbox.
|
||||||
- [ ] Add hero sandbox debug commands, keyboard shortcuts, and headless smoke script.
|
- [x] (2026-04-21 20:53Z) Added hero sandbox debug commands, keyboard shortcuts, and headless smoke script.
|
||||||
- [ ] Wire `HeroSandbox` into debug boot and validate existing boot modes still work.
|
- [x] (2026-04-21 20:53Z) Wired `HeroSandbox` into debug boot and validated existing boot modes still work.
|
||||||
- [ ] Run formatting, tests, Godot smokes, update this plan, and commit the completed slice.
|
- [x] (2026-04-21 20:53Z) Ran formatting, tests, Godot smokes, and updated this plan.
|
||||||
|
- [ ] Commit the completed slice.
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## Surprises & Discoveries
|
||||||
|
|
||||||
@@ -38,6 +39,12 @@ This slice does not implement weapon firing, collectibles as physical pickup act
|
|||||||
- Observation: There is no hero runtime code yet.
|
- Observation: There is no hero runtime code yet.
|
||||||
Evidence: `rg --files godot\scripts godot\scenes tests` lists bootstrap, content, debug, and menu files only; there are no `hero` scene or script folders.
|
Evidence: `rg --files godot\scripts godot\scenes tests` lists bootstrap, content, debug, and menu files only; there are no `hero` scene or script folders.
|
||||||
|
|
||||||
|
- Observation: Running `dotnet test SideScrollerGame.sln` and `dotnet build SideScrollerGame.sln` in parallel can still race on Godot's generated temp files.
|
||||||
|
Evidence: the baseline parallel validation reported `MSB3713` because `SideScrollerGame.Godot.AssemblyInfo.cs` was being used by another process. Running `dotnet test` and `dotnet build` serially succeeded.
|
||||||
|
|
||||||
|
- Observation: Godot generated `.cs.uid` files for every new C# hero script during the headless project scan.
|
||||||
|
Evidence: after `.\godot --headless --path godot --build-solutions --quit`, files such as `godot/scripts/hero/HeroActor.cs.uid`, `godot/scripts/hero/rules/HeroRuntimeService.cs.uid`, and `godot/scripts/debug/HeroSandboxController.cs.uid` appeared.
|
||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
|
||||||
- Decision: Implement hero gameplay rules as plain C# before Godot node behavior.
|
- Decision: Implement hero gameplay rules as plain C# before Godot node behavior.
|
||||||
@@ -62,7 +69,34 @@ This slice does not implement weapon firing, collectibles as physical pickup act
|
|||||||
|
|
||||||
## Outcomes & Retrospective
|
## Outcomes & Retrospective
|
||||||
|
|
||||||
No implementation has been performed yet. This plan defines the target result for Slice 4 and must be updated as code, tests, validation, and any course corrections happen.
|
Completed. Slice 4 added pure hero runtime rules under `godot/scripts/hero/rules/`, a controllable Godot hero actor, a hero state HUD, a dedicated hero sandbox scene, hero debug command ids and scene command handler support, hero-specific keyboard actions, `HeroSandbox` debug boot wiring, and a deterministic headless hero smoke script.
|
||||||
|
|
||||||
|
Validation completed from `D:\Code\zfxaction26_1`:
|
||||||
|
|
||||||
|
dotnet test SideScrollerGame.sln
|
||||||
|
Passed! - Failed: 0, Passed: 36, Skipped: 0, Total: 36
|
||||||
|
|
||||||
|
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=hero-sandbox --debug-script=hero-smoke --seed=444
|
||||||
|
Hero sandbox smoke succeeded
|
||||||
|
|
||||||
|
.\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: hero movement bounds were validated through the implemented actor logic and scene smoke compilation, but were not manually inspected in an editor window during this headless iteration.
|
||||||
|
|
||||||
## Context and Orientation
|
## Context and Orientation
|
||||||
|
|
||||||
@@ -496,3 +530,5 @@ In `godot/scripts/bootstrap/GameRoot.cs`, extend boot loading so:
|
|||||||
loads `res://scenes/debug/HeroSandbox.tscn`.
|
loads `res://scenes/debug/HeroSandbox.tscn`.
|
||||||
|
|
||||||
Revision note 2026-04-21: Created this ExecPlan from the current Slice 3 project state. The plan scopes Slice 4 to hero runtime rules, a controllable hero actor, a hero sandbox, hero debug commands, and headless smoke validation while leaving weapons, pickups as actors, squadron actors, enemies, and mission flow to later slices.
|
Revision note 2026-04-21: Created this ExecPlan from the current Slice 3 project state. The plan scopes Slice 4 to hero runtime rules, a controllable hero actor, a hero sandbox, hero debug commands, and headless smoke validation while leaving weapons, pickups as actors, squadron actors, enemies, and mission flow to later slices.
|
||||||
|
|
||||||
|
Revision note 2026-04-21: Updated this ExecPlan after implementation. Progress, discoveries, and outcomes now reflect the completed hero runtime, sandbox, smoke validation, serial build requirement, and remaining editor-inspection risk.
|
||||||
|
|||||||
@@ -132,3 +132,43 @@ debug_toggle_gameplay_bounds={
|
|||||||
"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)
|
"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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
debug_damage_hero={
|
||||||
|
"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":56,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
debug_kill_hero={
|
||||||
|
"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":57,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
debug_rebirth_hero={
|
||||||
|
"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":48,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
debug_add_points={
|
||||||
|
"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":90,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
debug_add_shield={
|
||||||
|
"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":88,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
debug_remove_shield={
|
||||||
|
"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":67,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
debug_toggle_primary_slot={
|
||||||
|
"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":86,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
debug_clear_hero_inventory={
|
||||||
|
"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":66,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[gd_scene load_steps=8 format=3 uid="uid://b1fxc23gkbqre"]
|
[gd_scene load_steps=9 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/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/DebugOverlay.cs" id="2_debug_overlay"]
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
[ext_resource type="PackedScene" path="res://scenes/debug/SmokeScene.tscn" id="4_smoke"]
|
[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/ContentBrowser.tscn" id="5_content_browser"]
|
||||||
[ext_resource type="PackedScene" path="res://scenes/debug/DebugSandbox.tscn" id="6_debug_sandbox"]
|
[ext_resource type="PackedScene" path="res://scenes/debug/DebugSandbox.tscn" id="6_debug_sandbox"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/debug/HeroSandbox.tscn" id="7_hero_sandbox"]
|
||||||
|
|
||||||
[node name="GameRoot" type="Node"]
|
[node name="GameRoot" type="Node"]
|
||||||
script = ExtResource("1_game_root")
|
script = ExtResource("1_game_root")
|
||||||
@@ -14,6 +15,7 @@ MenuScene = ExtResource("3_menu")
|
|||||||
SmokeScene = ExtResource("4_smoke")
|
SmokeScene = ExtResource("4_smoke")
|
||||||
ContentBrowserScene = ExtResource("5_content_browser")
|
ContentBrowserScene = ExtResource("5_content_browser")
|
||||||
DebugSandboxScene = ExtResource("6_debug_sandbox")
|
DebugSandboxScene = ExtResource("6_debug_sandbox")
|
||||||
|
HeroSandboxScene = ExtResource("7_hero_sandbox")
|
||||||
|
|
||||||
[node name="DebugCommandNode" type="Node" parent="."]
|
[node name="DebugCommandNode" type="Node" parent="."]
|
||||||
process_mode = 3
|
process_mode = 3
|
||||||
|
|||||||
88
godot/scenes/debug/HeroSandbox.tscn
Normal file
88
godot/scenes/debug/HeroSandbox.tscn
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
[gd_scene load_steps=4 format=3 uid="uid://dchtr3qrw2omq"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/debug/HeroSandboxController.cs" id="1_hero_sandbox"]
|
||||||
|
[ext_resource type="PackedScene" path="res://scenes/hero/Hero.tscn" id="2_hero"]
|
||||||
|
[ext_resource type="Script" path="res://scripts/hero/HeroStateHudController.cs" id="3_hero_hud"]
|
||||||
|
|
||||||
|
[node name="HeroSandbox" 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_hero_sandbox")
|
||||||
|
|
||||||
|
[node name="ButtonPanel" type="PanelContainer" parent="."]
|
||||||
|
process_mode = 3
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 10
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = 16.0
|
||||||
|
offset_top = 16.0
|
||||||
|
offset_right = -960.0
|
||||||
|
offset_bottom = 704.0
|
||||||
|
grow_horizontal = 2
|
||||||
|
custom_minimum_size = Vector2(220, 0)
|
||||||
|
|
||||||
|
[node name="Scroll" type="ScrollContainer" parent="ButtonPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Buttons" type="VBoxContainer" parent="ButtonPanel/Scroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="Playfield" type="Control" parent="."]
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 0
|
||||||
|
offset_left = 260.0
|
||||||
|
offset_top = 64.0
|
||||||
|
offset_right = 960.0
|
||||||
|
offset_bottom = 560.0
|
||||||
|
|
||||||
|
[node name="PlayBounds" type="ColorRect" parent="Playfield"]
|
||||||
|
layout_mode = 0
|
||||||
|
offset_left = 280.0
|
||||||
|
offset_top = 96.0
|
||||||
|
offset_right = 920.0
|
||||||
|
offset_bottom = 516.0
|
||||||
|
color = Color(0.1, 0.22, 0.28, 0.28)
|
||||||
|
|
||||||
|
[node name="Hero" parent="Playfield" instance=ExtResource("2_hero")]
|
||||||
|
position = Vector2(600, 306)
|
||||||
|
|
||||||
|
[node name="HudPanel" type="PanelContainer" parent="."]
|
||||||
|
process_mode = 3
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 11
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -348.0
|
||||||
|
offset_top = 16.0
|
||||||
|
offset_right = -16.0
|
||||||
|
offset_bottom = 268.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
script = ExtResource("3_hero_hud")
|
||||||
|
|
||||||
|
[node name="StateLabel" type="Label" parent="HudPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = "Hero: unbound"
|
||||||
|
|
||||||
|
[node name="LogPanel" type="PanelContainer" parent="."]
|
||||||
|
process_mode = 3
|
||||||
|
layout_mode = 1
|
||||||
|
anchors_preset = 11
|
||||||
|
anchor_left = 1.0
|
||||||
|
anchor_right = 1.0
|
||||||
|
offset_left = -348.0
|
||||||
|
offset_top = 288.0
|
||||||
|
offset_right = -16.0
|
||||||
|
offset_bottom = 704.0
|
||||||
|
grow_horizontal = 0
|
||||||
|
|
||||||
|
[node name="LogScroll" type="ScrollContainer" parent="LogPanel"]
|
||||||
|
layout_mode = 2
|
||||||
|
|
||||||
|
[node name="LogLabel" type="Label" parent="LogPanel/LogScroll"]
|
||||||
|
layout_mode = 2
|
||||||
|
text = ""
|
||||||
17
godot/scenes/hero/Hero.tscn
Normal file
17
godot/scenes/hero/Hero.tscn
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
[gd_scene load_steps=2 format=3 uid="uid://b1dwy8sg78kxp"]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/hero/HeroActor.cs" id="1_hero_actor"]
|
||||||
|
|
||||||
|
[node name="Hero" type="Node2D"]
|
||||||
|
script = ExtResource("1_hero_actor")
|
||||||
|
|
||||||
|
[node name="Body" type="Polygon2D" parent="."]
|
||||||
|
color = Color(0.2, 0.85, 1, 1)
|
||||||
|
polygon = PackedVector2Array(-22, -14, 24, 0, -22, 14, -10, 0)
|
||||||
|
|
||||||
|
[node name="Label" type="Label" parent="."]
|
||||||
|
offset_left = -22.0
|
||||||
|
offset_top = 18.0
|
||||||
|
offset_right = 30.0
|
||||||
|
offset_bottom = 41.0
|
||||||
|
text = "Hero"
|
||||||
@@ -28,6 +28,7 @@ public partial class GameRoot : Node
|
|||||||
DebugBootMode.Smoke => SmokeScene,
|
DebugBootMode.Smoke => SmokeScene,
|
||||||
DebugBootMode.ContentBrowser => ContentBrowserScene,
|
DebugBootMode.ContentBrowser => ContentBrowserScene,
|
||||||
DebugBootMode.DebugSandbox => DebugSandboxScene,
|
DebugBootMode.DebugSandbox => DebugSandboxScene,
|
||||||
|
DebugBootMode.HeroSandbox => HeroSandboxScene,
|
||||||
_ => MenuScene
|
_ => MenuScene
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,6 +73,9 @@ public partial class GameRoot : Node
|
|||||||
[Export]
|
[Export]
|
||||||
public PackedScene? DebugSandboxScene { get; set; }
|
public PackedScene? DebugSandboxScene { get; set; }
|
||||||
|
|
||||||
|
[Export]
|
||||||
|
public PackedScene? HeroSandboxScene { get; set; }
|
||||||
|
|
||||||
private Node? m_LoadedScene;
|
private Node? m_LoadedScene;
|
||||||
private DebugSettings? m_Settings;
|
private DebugSettings? m_Settings;
|
||||||
private DebugCommandNode? m_CommandNode;
|
private DebugCommandNode? m_CommandNode;
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ public enum DebugBootMode
|
|||||||
Menu,
|
Menu,
|
||||||
Smoke,
|
Smoke,
|
||||||
ContentBrowser,
|
ContentBrowser,
|
||||||
DebugSandbox
|
DebugSandbox,
|
||||||
|
HeroSandbox
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,12 @@ public partial class DebugOverlay : CanvasLayer
|
|||||||
Refresh();
|
Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetHeroSummary(string summary)
|
||||||
|
{
|
||||||
|
m_HeroSummary = summary;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
private void Refresh()
|
private void Refresh()
|
||||||
{
|
{
|
||||||
Label label = EnsureLabel();
|
Label label = EnsureLabel();
|
||||||
@@ -42,7 +48,8 @@ public partial class DebugOverlay : CanvasLayer
|
|||||||
|
|
||||||
DebugRuntimeState state = m_Service.State;
|
DebugRuntimeState state = m_Service.State;
|
||||||
Visible = state.OverlayVisible;
|
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}";
|
string heroLine = string.IsNullOrWhiteSpace(m_HeroSummary) ? string.Empty : $"\nHero: {m_HeroSummary}";
|
||||||
|
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}" + heroLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Label EnsureLabel()
|
private Label EnsureLabel()
|
||||||
@@ -74,4 +81,5 @@ public partial class DebugOverlay : CanvasLayer
|
|||||||
private DebugCommandService? m_Service;
|
private DebugCommandService? m_Service;
|
||||||
private DebugSettings? m_Settings;
|
private DebugSettings? m_Settings;
|
||||||
private string m_LoadedSceneId = "none";
|
private string m_LoadedSceneId = "none";
|
||||||
|
private string m_HeroSummary = string.Empty;
|
||||||
}
|
}
|
||||||
364
godot/scripts/debug/HeroSandboxController.cs
Normal file
364
godot/scripts/debug/HeroSandboxController.cs
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Godot;
|
||||||
|
using SideScrollerGame.Content.Samples;
|
||||||
|
using SideScrollerGame.Debug.Commands;
|
||||||
|
using SideScrollerGame.Hero;
|
||||||
|
using SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Debug;
|
||||||
|
|
||||||
|
public partial class HeroSandboxController : Control
|
||||||
|
{
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
ProcessMode = ProcessModeEnum.Always;
|
||||||
|
m_CommandNode = GetNodeOrNull<DebugCommandNode>("/root/GameRoot/DebugCommandNode");
|
||||||
|
m_Overlay = GetNodeOrNull<DebugOverlay>("/root/GameRoot/DebugOverlay");
|
||||||
|
if (m_CommandNode is null)
|
||||||
|
{
|
||||||
|
GD.PushError("Hero sandbox needs /root/GameRoot/DebugCommandNode.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BindSceneNodes();
|
||||||
|
RegisterHeroCommands();
|
||||||
|
CreateHeroRuntime();
|
||||||
|
CreateButtons();
|
||||||
|
|
||||||
|
m_CommandNode.Service.CommandExecuted += HandleCommandExecuted;
|
||||||
|
m_CommandNode.Service.RegisterRestartHandler(RestartSandbox);
|
||||||
|
|
||||||
|
if (ShouldRunHeroSmoke())
|
||||||
|
{
|
||||||
|
_ = RunHeroSmokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void _UnhandledInput(InputEvent @event)
|
||||||
|
{
|
||||||
|
if (@event.IsActionPressed("debug_damage_hero"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.DamageHero);
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_kill_hero"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.KillHero);
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_rebirth_hero"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.RebirthHero);
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_add_points"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.AddHeroPoints, "100");
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_add_shield"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.AddHeroShield);
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_remove_shield"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.RemoveHeroShield);
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_toggle_primary_slot"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.TogglePrimaryWeaponSlot);
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_clear_hero_inventory"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.ClearHeroInventory);
|
||||||
|
}
|
||||||
|
else if (@event.IsActionPressed("debug_toggle_invulnerability"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.ToggleInvulnerability);
|
||||||
|
}
|
||||||
|
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("quick_restart"))
|
||||||
|
{
|
||||||
|
Execute(DebugCommandId.RestartMission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BindSceneNodes()
|
||||||
|
{
|
||||||
|
m_Hero = GetNodeOrNull<HeroActor>("Playfield/Hero");
|
||||||
|
m_Hud = GetNodeOrNull<HeroStateHudController>("HudPanel");
|
||||||
|
m_LogLabel = GetNodeOrNull<Label>("LogPanel/LogScroll/LogLabel");
|
||||||
|
m_ButtonContainer = GetNodeOrNull<VBoxContainer>("ButtonPanel/Scroll/Buttons");
|
||||||
|
m_PlayBoundsRect = GetNodeOrNull<ColorRect>("Playfield/PlayBounds");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RegisterHeroCommands()
|
||||||
|
{
|
||||||
|
if (m_CommandNode is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (DebugCommandId commandId in s_HeroCommandIds)
|
||||||
|
{
|
||||||
|
m_CommandNode.Service.RegisterCommandHandler(commandId, argument => ExecuteHeroCommand(commandId, argument));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateHeroRuntime()
|
||||||
|
{
|
||||||
|
if (m_CommandNode is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
string difficultyId = m_CommandNode.Service.State.ActiveDifficultyId;
|
||||||
|
m_Runtime = new HeroRuntimeService(SampleContent.CreateRegistry(), HeroRuleConfig.CreateDefault(), difficultyId);
|
||||||
|
m_Runtime.StateChanged += _ => RefreshHeroViews();
|
||||||
|
m_Hero?.SetRuntime(m_Runtime);
|
||||||
|
m_Hero?.SetPlayBounds(PlayBounds);
|
||||||
|
if (m_Hero is not null)
|
||||||
|
{
|
||||||
|
m_Hero.GlobalPosition = PlayBounds.GetCenter();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_PlayBoundsRect is not null)
|
||||||
|
{
|
||||||
|
m_PlayBoundsRect.GlobalPosition = PlayBounds.Position;
|
||||||
|
m_PlayBoundsRect.Size = PlayBounds.Size;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_Hud?.Bind(m_Runtime);
|
||||||
|
RefreshHeroViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateButtons()
|
||||||
|
{
|
||||||
|
if (m_ButtonContainer is null || m_ButtonContainer.GetChildCount() > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddHeader("Hero");
|
||||||
|
AddButton("Damage", DebugCommandId.DamageHero);
|
||||||
|
AddButton("Heal", DebugCommandId.HealHero);
|
||||||
|
AddButton("Kill", DebugCommandId.KillHero);
|
||||||
|
AddButton("Rebirth", DebugCommandId.RebirthHero);
|
||||||
|
AddButton("+100 pts", DebugCommandId.AddHeroPoints, "100");
|
||||||
|
AddButton("+500 pts", DebugCommandId.AddHeroPoints, "500");
|
||||||
|
AddButton("Level 1", DebugCommandId.SetHeroLevel, "1");
|
||||||
|
AddButton("Level 4", DebugCommandId.SetHeroLevel, "4");
|
||||||
|
AddButton("+Shield", DebugCommandId.AddHeroShield);
|
||||||
|
AddButton("-Shield", DebugCommandId.RemoveHeroShield);
|
||||||
|
AddButton("Retries 0", DebugCommandId.SetHeroRetries, "0");
|
||||||
|
AddButton("Retries 3", DebugCommandId.SetHeroRetries, "3");
|
||||||
|
AddButton("Primary slot", DebugCommandId.TogglePrimaryWeaponSlot);
|
||||||
|
AddButton("Give primary", DebugCommandId.GivePrimaryWeapon, "weapon.primary.basic");
|
||||||
|
AddButton("Give secondary", DebugCommandId.GiveSecondaryWeapon, "weapon.secondary.vertical");
|
||||||
|
AddButton("+3 special", DebugCommandId.GiveSpecialAmmo, "3");
|
||||||
|
AddButton("Give mate", DebugCommandId.GiveSquadronMate, "squadron.orbit");
|
||||||
|
AddButton("Clear inventory", DebugCommandId.ClearHeroInventory);
|
||||||
|
|
||||||
|
AddHeader("Debug");
|
||||||
|
AddButton("Invulnerable", DebugCommandId.ToggleInvulnerability);
|
||||||
|
AddButton("Pause", DebugCommandId.TogglePause);
|
||||||
|
AddButton("Step", DebugCommandId.FrameStep);
|
||||||
|
AddButton("Restart", DebugCommandId.RestartMission);
|
||||||
|
AddButton("Reload", DebugCommandId.ReloadScene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddHeader(string text)
|
||||||
|
{
|
||||||
|
m_ButtonContainer?.AddChild(new Label { Text = text });
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddButton(string text, DebugCommandId commandId, string? argument = null)
|
||||||
|
{
|
||||||
|
Button button = new() { Text = text };
|
||||||
|
button.Pressed += () => Execute(commandId, argument);
|
||||||
|
m_ButtonContainer?.AddChild(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DebugCommandResult ExecuteHeroCommand(DebugCommandId commandId, string? argument)
|
||||||
|
{
|
||||||
|
if (m_Runtime is null)
|
||||||
|
{
|
||||||
|
return DebugCommandResult.Failure(commandId, "Hero runtime is not ready.", argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
HeroRuleResult result = commandId switch
|
||||||
|
{
|
||||||
|
DebugCommandId.DamageHero => m_Runtime.ApplyHit(m_CommandNode?.Service.State.Invulnerable ?? false),
|
||||||
|
DebugCommandId.HealHero => m_Runtime.AddShieldCharge(1),
|
||||||
|
DebugCommandId.KillHero => m_Runtime.Kill(),
|
||||||
|
DebugCommandId.RebirthHero => m_Runtime.Rebirth(),
|
||||||
|
DebugCommandId.AddHeroPoints => m_Runtime.AddPoints(ParseInt(argument, 100)),
|
||||||
|
DebugCommandId.SetHeroLevel => m_Runtime.SetLevel(ParseInt(argument, 1)),
|
||||||
|
DebugCommandId.AddHeroShield => m_Runtime.AddShieldCharge(1),
|
||||||
|
DebugCommandId.RemoveHeroShield => m_Runtime.RemoveShieldCharge(1),
|
||||||
|
DebugCommandId.SetHeroRetries => m_Runtime.SetRetryCount(ParseInt(argument, 3)),
|
||||||
|
DebugCommandId.TogglePrimaryWeaponSlot => m_Runtime.TogglePrimaryWeaponSlot(),
|
||||||
|
DebugCommandId.ClearHeroInventory => m_Runtime.ClearInventory(),
|
||||||
|
DebugCommandId.GivePrimaryWeapon => m_Runtime.ApplyPrimaryWeaponPickup(RequireArgument(argument, "weapon.primary.basic")),
|
||||||
|
DebugCommandId.GiveSecondaryWeapon => m_Runtime.ApplySecondaryWeaponPickup(RequireArgument(argument, "weapon.secondary.vertical")),
|
||||||
|
DebugCommandId.GiveSpecialAmmo => m_Runtime.AddSpecialAmmo(ParseInt(argument, 1)),
|
||||||
|
DebugCommandId.GiveSquadronMate => m_Runtime.ApplySquadronMatePickup(RequireArgument(argument, "squadron.orbit")),
|
||||||
|
_ => HeroRuleResult.Failure($"Unsupported hero command '{commandId}'.", m_Runtime.State)
|
||||||
|
};
|
||||||
|
|
||||||
|
RefreshHeroViews();
|
||||||
|
return result.Succeeded ? DebugCommandResult.Success(commandId, result.Message, argument) : DebugCommandResult.Failure(commandId, result.Message, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DebugCommandResult RestartSandbox()
|
||||||
|
{
|
||||||
|
CreateHeroRuntime();
|
||||||
|
return DebugCommandResult.Success(DebugCommandId.RestartMission, "Hero sandbox restarted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleCommandExecuted(DebugCommandResult result)
|
||||||
|
{
|
||||||
|
if (s_HeroCommandIds.Contains(result.CommandId))
|
||||||
|
{
|
||||||
|
string suffix = string.IsNullOrWhiteSpace(result.Argument) ? string.Empty : $" {result.Argument}";
|
||||||
|
AppendLog($"Hero command: {result.CommandId}{suffix}");
|
||||||
|
AppendLog($"Hero state: {BuildHeroSummary()}");
|
||||||
|
}
|
||||||
|
else if (result.CommandId is DebugCommandId.ToggleInvulnerability or DebugCommandId.TogglePause or DebugCommandId.FrameStep or DebugCommandId.RestartMission or DebugCommandId.ReloadScene)
|
||||||
|
{
|
||||||
|
string suffix = string.IsNullOrWhiteSpace(result.Argument) ? string.Empty : $" {result.Argument}";
|
||||||
|
AppendLog($"Command executed: {result.CommandId}{suffix}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.Succeeded)
|
||||||
|
{
|
||||||
|
AppendLog($"Command failed: {result.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RefreshHeroViews()
|
||||||
|
{
|
||||||
|
m_Hud?.Refresh();
|
||||||
|
m_Overlay?.SetHeroSummary(BuildHeroSummary());
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildHeroSummary()
|
||||||
|
{
|
||||||
|
if (m_Runtime is null)
|
||||||
|
{
|
||||||
|
return "unbound";
|
||||||
|
}
|
||||||
|
|
||||||
|
HeroRunState state = m_Runtime.State;
|
||||||
|
return $"{state.LifeState} level={state.Level} points={state.Points} shields={state.ShieldCharges} retries={state.RetryCount}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Execute(DebugCommandId commandId, string? argument = null)
|
||||||
|
{
|
||||||
|
m_CommandNode?.Execute(commandId, argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunHeroSmokeAsync()
|
||||||
|
{
|
||||||
|
await ToSignal(GetTree(), SceneTree.SignalName.ProcessFrame);
|
||||||
|
AppendLog("Hero sandbox smoke loaded");
|
||||||
|
|
||||||
|
bool succeeded = ExecuteAndRequire(DebugCommandId.SetDifficulty, "difficulty.normal") && ExecuteAndRequire(DebugCommandId.DamageHero) && ExecuteAndRequire(DebugCommandId.AddHeroPoints, "500") && ExecuteAndRequire(DebugCommandId.TogglePrimaryWeaponSlot) && ExecuteAndRequire(DebugCommandId.GivePrimaryWeapon, "weapon.primary.basic") && ExecuteAndRequire(DebugCommandId.GiveSpecialAmmo, "3") && ExecuteAndRequire(DebugCommandId.GiveSquadronMate, "squadron.orbit") && ExecuteAndRequire(DebugCommandId.ToggleInvulnerability) && ExecuteAndRequire(DebugCommandId.DamageHero) && ExecuteAndRequire(DebugCommandId.KillHero) && ExecuteAndRequire(DebugCommandId.RebirthHero) && ExecuteAndRequire(DebugCommandId.SetHeroRetries, "0") && ExecuteAndRequire(DebugCommandId.KillHero) && VerifyHeroSmokeState();
|
||||||
|
|
||||||
|
AppendLog(succeeded ? "Hero sandbox smoke succeeded" : "Hero sandbox 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 VerifyHeroSmokeState()
|
||||||
|
{
|
||||||
|
if (m_Runtime is null)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
HeroRunState state = m_Runtime.State;
|
||||||
|
return state.LifeState == HeroLifeState.GameOver && state.Level == 2 && state.Points == 500 && state.ShieldCharges == 0 && state.RetryCount == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 int ParseInt(string? value, int defaultValue)
|
||||||
|
{
|
||||||
|
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedValue) ? parsedValue : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string RequireArgument(string? argument, string defaultValue)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(argument) ? defaultValue : argument.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldRunHeroSmoke()
|
||||||
|
{
|
||||||
|
return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase) && OS.GetCmdlineUserArgs().Any(argument => argument.Equals("--debug-script=hero-smoke", StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Export]
|
||||||
|
public Rect2 PlayBounds { get; set; } = new(new Vector2(280.0f, 96.0f), new Vector2(640.0f, 420.0f));
|
||||||
|
|
||||||
|
private static readonly DebugCommandId[] s_HeroCommandIds =
|
||||||
|
[
|
||||||
|
DebugCommandId.DamageHero,
|
||||||
|
DebugCommandId.HealHero,
|
||||||
|
DebugCommandId.KillHero,
|
||||||
|
DebugCommandId.RebirthHero,
|
||||||
|
DebugCommandId.AddHeroPoints,
|
||||||
|
DebugCommandId.SetHeroLevel,
|
||||||
|
DebugCommandId.AddHeroShield,
|
||||||
|
DebugCommandId.RemoveHeroShield,
|
||||||
|
DebugCommandId.SetHeroRetries,
|
||||||
|
DebugCommandId.TogglePrimaryWeaponSlot,
|
||||||
|
DebugCommandId.ClearHeroInventory,
|
||||||
|
DebugCommandId.GivePrimaryWeapon,
|
||||||
|
DebugCommandId.GiveSecondaryWeapon,
|
||||||
|
DebugCommandId.GiveSpecialAmmo,
|
||||||
|
DebugCommandId.GiveSquadronMate
|
||||||
|
];
|
||||||
|
|
||||||
|
private DebugCommandNode? m_CommandNode;
|
||||||
|
private DebugOverlay? m_Overlay;
|
||||||
|
private HeroRuntimeService? m_Runtime;
|
||||||
|
private HeroActor? m_Hero;
|
||||||
|
private HeroStateHudController? m_Hud;
|
||||||
|
private Label? m_LogLabel;
|
||||||
|
private VBoxContainer? m_ButtonContainer;
|
||||||
|
private ColorRect? m_PlayBoundsRect;
|
||||||
|
}
|
||||||
1
godot/scripts/debug/HeroSandboxController.cs.uid
Normal file
1
godot/scripts/debug/HeroSandboxController.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dstsg1pgmok1o
|
||||||
@@ -20,5 +20,20 @@ public enum DebugCommandId
|
|||||||
ToggleGameplayBounds,
|
ToggleGameplayBounds,
|
||||||
ToggleInvulnerability,
|
ToggleInvulnerability,
|
||||||
ToggleInfiniteSpecialAmmo,
|
ToggleInfiniteSpecialAmmo,
|
||||||
ToggleNoEnemyFire
|
ToggleNoEnemyFire,
|
||||||
|
DamageHero,
|
||||||
|
HealHero,
|
||||||
|
KillHero,
|
||||||
|
RebirthHero,
|
||||||
|
AddHeroPoints,
|
||||||
|
SetHeroLevel,
|
||||||
|
AddHeroShield,
|
||||||
|
RemoveHeroShield,
|
||||||
|
SetHeroRetries,
|
||||||
|
TogglePrimaryWeaponSlot,
|
||||||
|
ClearHeroInventory,
|
||||||
|
GivePrimaryWeapon,
|
||||||
|
GiveSecondaryWeapon,
|
||||||
|
GiveSpecialAmmo,
|
||||||
|
GiveSquadronMate
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ public sealed class DebugCommandService
|
|||||||
DebugCommandId.ToggleInvulnerability => ToggleFlag(commandId, nameof(State.Invulnerable)),
|
DebugCommandId.ToggleInvulnerability => ToggleFlag(commandId, nameof(State.Invulnerable)),
|
||||||
DebugCommandId.ToggleInfiniteSpecialAmmo => ToggleFlag(commandId, nameof(State.InfiniteSpecialAmmo)),
|
DebugCommandId.ToggleInfiniteSpecialAmmo => ToggleFlag(commandId, nameof(State.InfiniteSpecialAmmo)),
|
||||||
DebugCommandId.ToggleNoEnemyFire => ToggleFlag(commandId, nameof(State.NoEnemyFire)),
|
DebugCommandId.ToggleNoEnemyFire => ToggleFlag(commandId, nameof(State.NoEnemyFire)),
|
||||||
_ => DebugCommandResult.Failure(commandId, $"Unsupported debug command '{commandId}'.", argument)
|
_ => ExecuteRegisteredCommand(commandId, argument)
|
||||||
};
|
};
|
||||||
|
|
||||||
CommandExecuted?.Invoke(result);
|
CommandExecuted?.Invoke(result);
|
||||||
@@ -64,6 +64,16 @@ public sealed class DebugCommandService
|
|||||||
m_RestartHandler = handler;
|
m_RestartHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void RegisterCommandHandler(DebugCommandId commandId, Func<string?, DebugCommandResult> handler)
|
||||||
|
{
|
||||||
|
m_CommandHandlers[commandId] = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearCommandHandler(DebugCommandId commandId)
|
||||||
|
{
|
||||||
|
m_CommandHandlers.Remove(commandId);
|
||||||
|
}
|
||||||
|
|
||||||
public DebugRuntimeState State { get; }
|
public DebugRuntimeState State { get; }
|
||||||
|
|
||||||
public event Action<DebugRuntimeState>? StateChanged;
|
public event Action<DebugRuntimeState>? StateChanged;
|
||||||
@@ -218,6 +228,11 @@ public sealed class DebugCommandService
|
|||||||
return DebugCommandResult.Success(commandId, message);
|
return DebugCommandResult.Success(commandId, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DebugCommandResult ExecuteRegisteredCommand(DebugCommandId commandId, string? argument)
|
||||||
|
{
|
||||||
|
return m_CommandHandlers.TryGetValue(commandId, out Func<string?, DebugCommandResult>? handler) ? handler(argument) : DebugCommandResult.Failure(commandId, $"Unsupported debug command '{commandId}'.", argument);
|
||||||
|
}
|
||||||
|
|
||||||
private string ToggleShowCollisionShapes()
|
private string ToggleShowCollisionShapes()
|
||||||
{
|
{
|
||||||
State.ShowCollisionShapes = !State.ShowCollisionShapes;
|
State.ShowCollisionShapes = !State.ShowCollisionShapes;
|
||||||
@@ -276,6 +291,7 @@ public sealed class DebugCommandService
|
|||||||
private static readonly HashSet<double> s_SupportedTimeScales = [0.25, 0.5, 1.0, 2.0, 4.0];
|
private static readonly HashSet<double> s_SupportedTimeScales = [0.25, 0.5, 1.0, 2.0, 4.0];
|
||||||
|
|
||||||
private readonly ContentRegistry m_Registry;
|
private readonly ContentRegistry m_Registry;
|
||||||
|
private readonly Dictionary<DebugCommandId, Func<string?, DebugCommandResult>> m_CommandHandlers = [];
|
||||||
private Func<string, DebugCommandResult>? m_SpawnHandler;
|
private Func<string, DebugCommandResult>? m_SpawnHandler;
|
||||||
private Func<string, DebugCommandResult>? m_TimelineJumpHandler;
|
private Func<string, DebugCommandResult>? m_TimelineJumpHandler;
|
||||||
private Func<DebugCommandResult>? m_ReloadHandler;
|
private Func<DebugCommandResult>? m_ReloadHandler;
|
||||||
|
|||||||
51
godot/scripts/hero/HeroActor.cs
Normal file
51
godot/scripts/hero/HeroActor.cs
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using Godot;
|
||||||
|
using SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero;
|
||||||
|
|
||||||
|
public partial class HeroActor : Node2D
|
||||||
|
{
|
||||||
|
public override void _PhysicsProcess(double delta)
|
||||||
|
{
|
||||||
|
if (m_Runtime is not null && m_Runtime.State.LifeState != HeroLifeState.Alive)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector2 direction = Input.GetVector("move_left", "move_right", "move_up", "move_down");
|
||||||
|
GlobalPosition = ClampToBounds(GlobalPosition + (direction * MoveSpeed * (float)delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetRuntime(HeroRuntimeService runtime)
|
||||||
|
{
|
||||||
|
m_Runtime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetPlayBounds(Rect2 bounds)
|
||||||
|
{
|
||||||
|
PlayBounds = bounds;
|
||||||
|
GlobalPosition = ClampToBounds(GlobalPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Export]
|
||||||
|
public float MoveSpeed { get; set; } = 320.0f;
|
||||||
|
|
||||||
|
[Export]
|
||||||
|
public Rect2 PlayBounds { get; set; } = new(new Vector2(260.0f, 96.0f), new Vector2(640.0f, 420.0f));
|
||||||
|
|
||||||
|
private Vector2 ClampToBounds(Vector2 position)
|
||||||
|
{
|
||||||
|
if (PlayBounds.Size == Vector2.Zero)
|
||||||
|
{
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
|
||||||
|
float x = Mathf.Clamp(position.X, PlayBounds.Position.X, PlayBounds.Position.X + PlayBounds.Size.X);
|
||||||
|
float y = Mathf.Clamp(position.Y, PlayBounds.Position.Y, PlayBounds.Position.Y + PlayBounds.Size.Y);
|
||||||
|
return new Vector2(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HeroRuntimeService? m_Runtime;
|
||||||
|
}
|
||||||
1
godot/scripts/hero/HeroActor.cs.uid
Normal file
1
godot/scripts/hero/HeroActor.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://df8y8nyfawd51
|
||||||
75
godot/scripts/hero/HeroStateHudController.cs
Normal file
75
godot/scripts/hero/HeroStateHudController.cs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using Godot;
|
||||||
|
using SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero;
|
||||||
|
|
||||||
|
public partial class HeroStateHudController : Control
|
||||||
|
{
|
||||||
|
public override void _Ready()
|
||||||
|
{
|
||||||
|
EnsureLabel();
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Bind(HeroRuntimeService runtime)
|
||||||
|
{
|
||||||
|
if (m_Runtime is not null)
|
||||||
|
{
|
||||||
|
m_Runtime.StateChanged -= HandleStateChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_Runtime = runtime;
|
||||||
|
runtime.StateChanged += HandleStateChanged;
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Refresh()
|
||||||
|
{
|
||||||
|
Label label = EnsureLabel();
|
||||||
|
if (m_Runtime is null)
|
||||||
|
{
|
||||||
|
label.Text = "Hero: unbound";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
HeroRunState state = m_Runtime.State;
|
||||||
|
string slots = string.Join(", ", state.PrimaryWeaponSlots.Select((weaponId, index) => index == state.SelectedPrimaryWeaponSlotIndex ? $"[{Display(weaponId)}]" : Display(weaponId)));
|
||||||
|
string nextThreshold = m_Runtime.NextPointThreshold?.ToString(CultureInfo.InvariantCulture) ?? "max";
|
||||||
|
label.Text = $"Hero: {state.LifeState}\n" + $"Level: {state.Level} Points: {state.Points} Next: {nextThreshold}\n" + $"Shields: {state.ShieldCharges} Retries: {state.RetryCount}\n" + $"Primary: {slots}\n" + $"Secondary: {Display(state.CurrentSecondaryWeaponId)}\n" + $"Special: {Display(state.CurrentSpecialWeaponId)} ammo={state.SpecialAmmo}\n" + $"Squadron: {Display(state.SquadronMateTypeId)} x{state.SquadronMateCount}\n" + $"Last: {state.LastStateChange}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleStateChanged(HeroRunState state)
|
||||||
|
{
|
||||||
|
Refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Label EnsureLabel()
|
||||||
|
{
|
||||||
|
if (m_StateLabel is not null)
|
||||||
|
{
|
||||||
|
return m_StateLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_StateLabel = GetNodeOrNull<Label>("StateLabel");
|
||||||
|
if (m_StateLabel is null)
|
||||||
|
{
|
||||||
|
m_StateLabel = new Label { Name = "StateLabel" };
|
||||||
|
AddChild(m_StateLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
m_StateLabel.AutowrapMode = TextServer.AutowrapMode.WordSmart;
|
||||||
|
return m_StateLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Display(string? value)
|
||||||
|
{
|
||||||
|
return string.IsNullOrWhiteSpace(value) ? "empty" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HeroRuntimeService? m_Runtime;
|
||||||
|
private Label? m_StateLabel;
|
||||||
|
}
|
||||||
1
godot/scripts/hero/HeroStateHudController.cs.uid
Normal file
1
godot/scripts/hero/HeroStateHudController.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dmqj2ouqe5cvq
|
||||||
10
godot/scripts/hero/rules/HeroLifeState.cs
Normal file
10
godot/scripts/hero/rules/HeroLifeState.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
public enum HeroLifeState
|
||||||
|
{
|
||||||
|
Alive,
|
||||||
|
Dead,
|
||||||
|
GameOver
|
||||||
|
}
|
||||||
1
godot/scripts/hero/rules/HeroLifeState.cs.uid
Normal file
1
godot/scripts/hero/rules/HeroLifeState.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://cr5kwlw3ovauj
|
||||||
7
godot/scripts/hero/rules/HeroMissionSnapshot.cs
Normal file
7
godot/scripts/hero/rules/HeroMissionSnapshot.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
public sealed record HeroMissionSnapshot(string ActiveDifficultyId, HeroLifeState LifeState, int Level, int Points, int ShieldCharges, int RetryCount, IReadOnlyList<string?> PrimaryWeaponSlots, int SelectedPrimaryWeaponSlotIndex, string CurrentSecondaryWeaponId, string CurrentSpecialWeaponId, int SpecialAmmo, string SquadronMateTypeId, int SquadronMateCount);
|
||||||
1
godot/scripts/hero/rules/HeroMissionSnapshot.cs.uid
Normal file
1
godot/scripts/hero/rules/HeroMissionSnapshot.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://chkspscrqv1mi
|
||||||
38
godot/scripts/hero/rules/HeroRuleConfig.cs
Normal file
38
godot/scripts/hero/rules/HeroRuleConfig.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
public sealed class HeroRuleConfig
|
||||||
|
{
|
||||||
|
public HeroRuleConfig(int primaryWeaponSlotCount, string basePrimaryWeaponId, string baseSecondaryWeaponId, string defaultSpecialWeaponId, int defaultSpecialAmmo, int maxSquadronMateCount, IReadOnlyList<int> pointThresholds)
|
||||||
|
{
|
||||||
|
PrimaryWeaponSlotCount = primaryWeaponSlotCount;
|
||||||
|
BasePrimaryWeaponId = basePrimaryWeaponId;
|
||||||
|
BaseSecondaryWeaponId = baseSecondaryWeaponId;
|
||||||
|
DefaultSpecialWeaponId = defaultSpecialWeaponId;
|
||||||
|
DefaultSpecialAmmo = defaultSpecialAmmo;
|
||||||
|
MaxSquadronMateCount = maxSquadronMateCount;
|
||||||
|
PointThresholds = pointThresholds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HeroRuleConfig CreateDefault()
|
||||||
|
{
|
||||||
|
return new HeroRuleConfig(2, "weapon.primary.basic", "weapon.secondary.vertical", "weapon.special.bomb", 12, 4, [500, 1500, 3000]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int PrimaryWeaponSlotCount { get; }
|
||||||
|
|
||||||
|
public string BasePrimaryWeaponId { get; }
|
||||||
|
|
||||||
|
public string BaseSecondaryWeaponId { get; }
|
||||||
|
|
||||||
|
public string DefaultSpecialWeaponId { get; }
|
||||||
|
|
||||||
|
public int DefaultSpecialAmmo { get; }
|
||||||
|
|
||||||
|
public int MaxSquadronMateCount { get; }
|
||||||
|
|
||||||
|
public IReadOnlyList<int> PointThresholds { get; }
|
||||||
|
}
|
||||||
1
godot/scripts/hero/rules/HeroRuleConfig.cs.uid
Normal file
1
godot/scripts/hero/rules/HeroRuleConfig.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://dnk3adcvb0xma
|
||||||
16
godot/scripts/hero/rules/HeroRuleResult.cs
Normal file
16
godot/scripts/hero/rules/HeroRuleResult.cs
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
public sealed record HeroRuleResult(bool Succeeded, string Message, HeroRunState State)
|
||||||
|
{
|
||||||
|
public static HeroRuleResult Success(string message, HeroRunState state)
|
||||||
|
{
|
||||||
|
return new HeroRuleResult(true, message, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static HeroRuleResult Failure(string message, HeroRunState state)
|
||||||
|
{
|
||||||
|
return new HeroRuleResult(false, message, state);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
godot/scripts/hero/rules/HeroRuleResult.cs.uid
Normal file
1
godot/scripts/hero/rules/HeroRuleResult.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bvkra6itukg8p
|
||||||
79
godot/scripts/hero/rules/HeroRunState.cs
Normal file
79
godot/scripts/hero/rules/HeroRunState.cs
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
public sealed class HeroRunState
|
||||||
|
{
|
||||||
|
public HeroRunState(string activeDifficultyId, int shieldCharges, int retryCount, HeroRuleConfig config)
|
||||||
|
{
|
||||||
|
ActiveDifficultyId = activeDifficultyId;
|
||||||
|
ShieldCharges = shieldCharges;
|
||||||
|
RetryCount = retryCount;
|
||||||
|
m_PrimaryWeaponSlots = Enumerable.Repeat<string?>(null, config.PrimaryWeaponSlotCount).ToList();
|
||||||
|
ResetInventory(config);
|
||||||
|
LastStateChange = "Hero ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
public int FindEmptyPrimarySlot()
|
||||||
|
{
|
||||||
|
return m_PrimaryWeaponSlots.FindIndex(string.IsNullOrWhiteSpace);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ReplacePrimaryWeaponSlot(int index, string? weaponId)
|
||||||
|
{
|
||||||
|
m_PrimaryWeaponSlots[index] = weaponId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ResetInventory(HeroRuleConfig config)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < m_PrimaryWeaponSlots.Count; i++)
|
||||||
|
{
|
||||||
|
m_PrimaryWeaponSlots[i] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m_PrimaryWeaponSlots.Count > 0)
|
||||||
|
{
|
||||||
|
m_PrimaryWeaponSlots[0] = config.BasePrimaryWeaponId;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectedPrimaryWeaponSlotIndex = 0;
|
||||||
|
CurrentSecondaryWeaponId = config.BaseSecondaryWeaponId;
|
||||||
|
CurrentSpecialWeaponId = config.DefaultSpecialWeaponId;
|
||||||
|
SpecialAmmo = config.DefaultSpecialAmmo;
|
||||||
|
SquadronMateTypeId = string.Empty;
|
||||||
|
SquadronMateCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string ActiveDifficultyId { get; set; }
|
||||||
|
|
||||||
|
public HeroLifeState LifeState { get; set; } = HeroLifeState.Alive;
|
||||||
|
|
||||||
|
public int Level { get; set; } = 1;
|
||||||
|
|
||||||
|
public int Points { get; set; }
|
||||||
|
|
||||||
|
public int ShieldCharges { get; set; }
|
||||||
|
|
||||||
|
public int RetryCount { get; set; }
|
||||||
|
|
||||||
|
public IReadOnlyList<string?> PrimaryWeaponSlots => m_PrimaryWeaponSlots;
|
||||||
|
|
||||||
|
public int SelectedPrimaryWeaponSlotIndex { get; set; }
|
||||||
|
|
||||||
|
public string CurrentSecondaryWeaponId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string CurrentSpecialWeaponId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int SpecialAmmo { get; set; }
|
||||||
|
|
||||||
|
public string SquadronMateTypeId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public int SquadronMateCount { get; set; }
|
||||||
|
|
||||||
|
public string LastStateChange { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
private readonly List<string?> m_PrimaryWeaponSlots;
|
||||||
|
}
|
||||||
1
godot/scripts/hero/rules/HeroRunState.cs.uid
Normal file
1
godot/scripts/hero/rules/HeroRunState.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://bet8wlbxg4xst
|
||||||
248
godot/scripts/hero/rules/HeroRuntimeService.cs
Normal file
248
godot/scripts/hero/rules/HeroRuntimeService.cs
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using SideScrollerGame.Content;
|
||||||
|
using SideScrollerGame.Content.Definitions;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
public sealed class HeroRuntimeService
|
||||||
|
{
|
||||||
|
public HeroRuntimeService(ContentRegistry registry, HeroRuleConfig config, string difficultyId)
|
||||||
|
{
|
||||||
|
m_Registry = registry;
|
||||||
|
Config = config;
|
||||||
|
m_ActiveDifficulty = ResolveDifficulty(difficultyId);
|
||||||
|
State = new HeroRunState(m_ActiveDifficulty.Id, m_ActiveDifficulty.HeroStartingShieldCharges, m_ActiveDifficulty.HeroRetryCount, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult ApplyHit(bool invulnerable)
|
||||||
|
{
|
||||||
|
if (State.LifeState != HeroLifeState.Alive)
|
||||||
|
{
|
||||||
|
return Fail("Hero is not alive");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invulnerable)
|
||||||
|
{
|
||||||
|
return Succeed("Hero ignored hit while invulnerable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State.ShieldCharges > 0)
|
||||||
|
{
|
||||||
|
State.ShieldCharges--;
|
||||||
|
return Succeed($"Hero hit: shield {State.ShieldCharges}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return EnterDeathState("Hero killed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult Kill()
|
||||||
|
{
|
||||||
|
if (State.LifeState != HeroLifeState.Alive)
|
||||||
|
{
|
||||||
|
return Fail("Hero is not alive");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.ShieldCharges = 0;
|
||||||
|
return EnterDeathState("Hero killed");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult Rebirth()
|
||||||
|
{
|
||||||
|
if (State.LifeState != HeroLifeState.Dead)
|
||||||
|
{
|
||||||
|
return Fail("Hero is not waiting for rebirth");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State.RetryCount <= 0)
|
||||||
|
{
|
||||||
|
State.LifeState = HeroLifeState.GameOver;
|
||||||
|
return Succeed("Game over");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.RetryCount--;
|
||||||
|
State.LifeState = HeroLifeState.Alive;
|
||||||
|
State.ShieldCharges = m_ActiveDifficulty.HeroStartingShieldCharges;
|
||||||
|
State.ResetInventory(Config);
|
||||||
|
return Succeed("Hero reborn");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult AddPoints(int points)
|
||||||
|
{
|
||||||
|
if (points <= 0)
|
||||||
|
{
|
||||||
|
return Fail($"Invalid point amount {points}");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.Points += points;
|
||||||
|
int targetLevel = CalculateLevel(State.Points);
|
||||||
|
int gainedLevels = Math.Max(0, targetLevel - State.Level);
|
||||||
|
State.Level = targetLevel;
|
||||||
|
State.ShieldCharges += gainedLevels;
|
||||||
|
return Succeed(gainedLevels == 0 ? $"Points added: {points}" : $"Points added: {points}; level {State.Level}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult SetLevel(int level)
|
||||||
|
{
|
||||||
|
if (level < 1)
|
||||||
|
{
|
||||||
|
return Fail($"Invalid hero level {level}");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.Level = level;
|
||||||
|
return Succeed($"Hero level set to {level}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult AddShieldCharge(int amount)
|
||||||
|
{
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
return Fail($"Invalid shield amount {amount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.ShieldCharges += amount;
|
||||||
|
return Succeed($"Shield added: {State.ShieldCharges}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult RemoveShieldCharge(int amount)
|
||||||
|
{
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
return Fail($"Invalid shield amount {amount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.ShieldCharges = Math.Max(0, State.ShieldCharges - amount);
|
||||||
|
return Succeed($"Shield removed: {State.ShieldCharges}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult SetRetryCount(int retryCount)
|
||||||
|
{
|
||||||
|
if (retryCount < 0)
|
||||||
|
{
|
||||||
|
return Fail($"Invalid retry count {retryCount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.RetryCount = retryCount;
|
||||||
|
return Succeed($"Retries set to {retryCount}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult TogglePrimaryWeaponSlot()
|
||||||
|
{
|
||||||
|
if (State.PrimaryWeaponSlots.Count == 0)
|
||||||
|
{
|
||||||
|
return Fail("No primary weapon slots");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.SelectedPrimaryWeaponSlotIndex = (State.SelectedPrimaryWeaponSlotIndex + 1) % State.PrimaryWeaponSlots.Count;
|
||||||
|
return Succeed($"Primary slot {State.SelectedPrimaryWeaponSlotIndex}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult ApplyPrimaryWeaponPickup(string weaponId)
|
||||||
|
{
|
||||||
|
if (!m_Registry.Weapons.ContainsKey(weaponId))
|
||||||
|
{
|
||||||
|
return Fail($"Unknown primary weapon '{weaponId}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
int targetSlot = State.FindEmptyPrimarySlot();
|
||||||
|
if (targetSlot < 0)
|
||||||
|
{
|
||||||
|
targetSlot = State.SelectedPrimaryWeaponSlotIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
State.ReplacePrimaryWeaponSlot(targetSlot, weaponId);
|
||||||
|
return Succeed($"Primary weapon {weaponId} in slot {targetSlot}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult ApplySecondaryWeaponPickup(string weaponId)
|
||||||
|
{
|
||||||
|
if (!m_Registry.Weapons.ContainsKey(weaponId))
|
||||||
|
{
|
||||||
|
return Fail($"Unknown secondary weapon '{weaponId}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.CurrentSecondaryWeaponId = weaponId;
|
||||||
|
return Succeed($"Secondary weapon {weaponId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult AddSpecialAmmo(int amount)
|
||||||
|
{
|
||||||
|
State.SpecialAmmo = Math.Max(0, State.SpecialAmmo + amount);
|
||||||
|
return Succeed($"Special ammo {State.SpecialAmmo}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult ApplySquadronMatePickup(string squadronMateTypeId)
|
||||||
|
{
|
||||||
|
if (!m_Registry.SquadronMateTypes.ContainsKey(squadronMateTypeId))
|
||||||
|
{
|
||||||
|
return Fail($"Unknown squadron mate '{squadronMateTypeId}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
State.SquadronMateTypeId = squadronMateTypeId;
|
||||||
|
State.SquadronMateCount = Math.Min(Config.MaxSquadronMateCount, State.SquadronMateCount + 1);
|
||||||
|
return Succeed($"Squadron mates {State.SquadronMateCount} {squadronMateTypeId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRuleResult ClearInventory()
|
||||||
|
{
|
||||||
|
State.ResetInventory(Config);
|
||||||
|
return Succeed("Hero inventory cleared");
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroMissionSnapshot CreateMissionSnapshot()
|
||||||
|
{
|
||||||
|
return new HeroMissionSnapshot(State.ActiveDifficultyId, State.LifeState, State.Level, State.Points, State.ShieldCharges, State.RetryCount, State.PrimaryWeaponSlots.ToList(), State.SelectedPrimaryWeaponSlotIndex, State.CurrentSecondaryWeaponId, State.CurrentSpecialWeaponId, State.SpecialAmmo, State.SquadronMateTypeId, State.SquadronMateCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? NextPointThreshold
|
||||||
|
{
|
||||||
|
get { return Config.PointThresholds.Cast<int?>().FirstOrDefault(threshold => threshold > State.Points); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public HeroRunState State { get; }
|
||||||
|
|
||||||
|
public HeroRuleConfig Config { get; }
|
||||||
|
|
||||||
|
public event Action<HeroRunState>? StateChanged;
|
||||||
|
|
||||||
|
private HeroRuleResult EnterDeathState(string message)
|
||||||
|
{
|
||||||
|
State.ShieldCharges = 0;
|
||||||
|
State.ResetInventory(Config);
|
||||||
|
State.LifeState = State.RetryCount > 0 ? HeroLifeState.Dead : HeroLifeState.GameOver;
|
||||||
|
return Succeed(State.LifeState == HeroLifeState.GameOver ? "Game over" : message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HeroRuleResult Succeed(string message)
|
||||||
|
{
|
||||||
|
State.LastStateChange = message;
|
||||||
|
StateChanged?.Invoke(State);
|
||||||
|
return HeroRuleResult.Success(message, State);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HeroRuleResult Fail(string message)
|
||||||
|
{
|
||||||
|
State.LastStateChange = message;
|
||||||
|
return HeroRuleResult.Failure(message, State);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CalculateLevel(int points)
|
||||||
|
{
|
||||||
|
return 1 + Config.PointThresholds.Count(threshold => points >= threshold);
|
||||||
|
}
|
||||||
|
|
||||||
|
private DifficultyDefinition ResolveDifficulty(string difficultyId)
|
||||||
|
{
|
||||||
|
if (m_Registry.Difficulties.TryGetValue(difficultyId, out DifficultyDefinition? difficulty))
|
||||||
|
{
|
||||||
|
return difficulty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return m_Registry.DifficultyDefinitions.First();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly ContentRegistry m_Registry;
|
||||||
|
private readonly DifficultyDefinition m_ActiveDifficulty;
|
||||||
|
}
|
||||||
1
godot/scripts/hero/rules/HeroRuntimeService.cs.uid
Normal file
1
godot/scripts/hero/rules/HeroRuntimeService.cs.uid
Normal file
@@ -0,0 +1 @@
|
|||||||
|
uid://hfyvh0qvd7uv
|
||||||
191
tests/SideScrollerGame.Content.Tests/HeroRuntimeServiceTests.cs
Normal file
191
tests/SideScrollerGame.Content.Tests/HeroRuntimeServiceTests.cs
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using SideScrollerGame.Content;
|
||||||
|
using SideScrollerGame.Content.Samples;
|
||||||
|
using SideScrollerGame.Hero.Rules;
|
||||||
|
|
||||||
|
namespace SideScrollerGame.Content.Tests;
|
||||||
|
|
||||||
|
public class HeroRuntimeServiceTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void StartState_UsesDifficultyShieldsAndRetries()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService("difficulty.normal");
|
||||||
|
|
||||||
|
Assert.Equal(HeroLifeState.Alive, service.State.LifeState);
|
||||||
|
Assert.Equal(3, service.State.ShieldCharges);
|
||||||
|
Assert.Equal(3, service.State.RetryCount);
|
||||||
|
Assert.Equal("weapon.primary.basic", service.State.PrimaryWeaponSlots[0]);
|
||||||
|
Assert.Null(service.State.PrimaryWeaponSlots[1]);
|
||||||
|
Assert.Equal("weapon.secondary.vertical", service.State.CurrentSecondaryWeaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyHit_ConsumesShieldsBeforeDeath()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.ApplyHit(false);
|
||||||
|
|
||||||
|
Assert.Equal(HeroLifeState.Alive, service.State.LifeState);
|
||||||
|
Assert.Equal(2, service.State.ShieldCharges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyHit_WithNoShieldsAndRetries_EntersDeadState()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
service.RemoveShieldCharge(3);
|
||||||
|
|
||||||
|
service.ApplyHit(false);
|
||||||
|
|
||||||
|
Assert.Equal(HeroLifeState.Dead, service.State.LifeState);
|
||||||
|
Assert.Equal(3, service.State.RetryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rebirth_ConsumesRetryAndRestoresDifficultyShields()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
service.Kill();
|
||||||
|
|
||||||
|
HeroRuleResult result = service.Rebirth();
|
||||||
|
|
||||||
|
Assert.True(result.Succeeded);
|
||||||
|
Assert.Equal(HeroLifeState.Alive, service.State.LifeState);
|
||||||
|
Assert.Equal(2, service.State.RetryCount);
|
||||||
|
Assert.Equal(3, service.State.ShieldCharges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Kill_WithZeroRetries_EntersGameOver()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
service.SetRetryCount(0);
|
||||||
|
|
||||||
|
service.Kill();
|
||||||
|
|
||||||
|
Assert.Equal(HeroLifeState.GameOver, service.State.LifeState);
|
||||||
|
Assert.Equal(0, service.State.RetryCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddPoints_LevelsUpAndAddsShields()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.AddPoints(500);
|
||||||
|
|
||||||
|
Assert.Equal(2, service.State.Level);
|
||||||
|
Assert.Equal(500, service.State.Points);
|
||||||
|
Assert.Equal(4, service.State.ShieldCharges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AddPoints_CanCrossMultipleThresholds()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.AddPoints(3000);
|
||||||
|
|
||||||
|
Assert.Equal(4, service.State.Level);
|
||||||
|
Assert.Equal(6, service.State.ShieldCharges);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PrimaryPickup_FillsEmptySlotBeforeReplacingSelectedSlot()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.ApplyPrimaryWeaponPickup("weapon.primary.basic");
|
||||||
|
Assert.Equal("weapon.primary.basic", service.State.PrimaryWeaponSlots[1]);
|
||||||
|
|
||||||
|
service.TogglePrimaryWeaponSlot();
|
||||||
|
service.ApplyPrimaryWeaponPickup("weapon.primary.basic");
|
||||||
|
|
||||||
|
Assert.Equal(1, service.State.SelectedPrimaryWeaponSlotIndex);
|
||||||
|
Assert.Equal("weapon.primary.basic", service.State.PrimaryWeaponSlots[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TogglePrimaryWeaponSlot_ChangesSelectedSlot()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.TogglePrimaryWeaponSlot();
|
||||||
|
|
||||||
|
Assert.Equal(1, service.State.SelectedPrimaryWeaponSlotIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SecondaryPickup_ReplacesCurrentSecondary()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.ApplySecondaryWeaponPickup("weapon.secondary.vertical");
|
||||||
|
|
||||||
|
Assert.Equal("weapon.secondary.vertical", service.State.CurrentSecondaryWeaponId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SpecialAmmo_DoesNotDropBelowZero()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.AddSpecialAmmo(-100);
|
||||||
|
|
||||||
|
Assert.Equal(0, service.State.SpecialAmmo);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SquadronMatePickup_ChangesTypeAndCapsCount()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
for (int i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
service.ApplySquadronMatePickup("squadron.orbit");
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal("squadron.orbit", service.State.SquadronMateTypeId);
|
||||||
|
Assert.Equal(4, service.State.SquadronMateCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClearInventory_ResetsCollectibleDerivedState()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
service.TogglePrimaryWeaponSlot();
|
||||||
|
service.ApplyPrimaryWeaponPickup("weapon.primary.basic");
|
||||||
|
service.AddSpecialAmmo(3);
|
||||||
|
service.ApplySquadronMatePickup("squadron.orbit");
|
||||||
|
|
||||||
|
service.ClearInventory();
|
||||||
|
|
||||||
|
Assert.Equal(0, service.State.SelectedPrimaryWeaponSlotIndex);
|
||||||
|
Assert.Equal("weapon.primary.basic", service.State.PrimaryWeaponSlots[0]);
|
||||||
|
Assert.Null(service.State.PrimaryWeaponSlots[1]);
|
||||||
|
Assert.Equal("weapon.secondary.vertical", service.State.CurrentSecondaryWeaponId);
|
||||||
|
Assert.Equal(12, service.State.SpecialAmmo);
|
||||||
|
Assert.Equal(string.Empty, service.State.SquadronMateTypeId);
|
||||||
|
Assert.Equal(0, service.State.SquadronMateCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ApplyHit_WithInvulnerability_PreventsDamage()
|
||||||
|
{
|
||||||
|
HeroRuntimeService service = CreateService();
|
||||||
|
|
||||||
|
service.ApplyHit(true);
|
||||||
|
|
||||||
|
Assert.Equal(HeroLifeState.Alive, service.State.LifeState);
|
||||||
|
Assert.Equal(3, service.State.ShieldCharges);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HeroRuntimeService CreateService(string difficultyId = "difficulty.normal")
|
||||||
|
{
|
||||||
|
ContentRegistry registry = SampleContent.CreateRegistry();
|
||||||
|
return new HeroRuntimeService(registry, HeroRuleConfig.CreateDefault(), difficultyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user