Compare commits

...

24 Commits

Author SHA1 Message Date
4d651e1a0e slice 4 finished 2026-04-21 23:55:40 +02:00
762c8969ab Add hero runtime 2026-04-21 22:54:14 +02:00
67737f3ba8 Add hero runtime exec plan 2026-04-21 22:25:49 +02:00
cc51f4a6e8 Add debug foundation 2026-04-21 21:16:30 +02:00
693f31dd50 Add slice 3 execution plan 2026-04-21 21:05:19 +02:00
efcc1ba209 Add core content definitions 2026-04-21 19:47:25 +02:00
d4b3c221b2 Add slice 2 execution plan 2026-04-21 19:00:36 +02:00
bf5c51abaf slice1 2026-04-21 18:57:17 +02:00
12a2868c9c Add Godot project shell 2026-04-21 18:54:38 +02:00
0d9957fa62 Add slice 1 execution plan 2026-04-21 18:38:31 +02:00
47a0104357 Add implementation plan 2026-04-21 18:21:39 +02:00
213287c43d Add difficulty level design 2026-04-21 17:38:20 +02:00
ed703d91f4 tech design 2026-04-21 17:37:04 +02:00
126b6fdd58 Add side-scrolling shooter systems design 2026-04-21 17:02:03 +02:00
551757d521 revert old sim groundwork 2026-04-21 17:00:51 +02:00
be68ac9fc1 Move fixpoint math 2026-04-18 21:29:56 +02:00
21a8b8bedb Add platformer locomotion slice 2026-04-16 12:32:38 +02:00
45181d1f78 Add bounds hazards and triggers 2026-04-16 11:50:37 +02:00
c79d5c8f0a cleanup repo 2026-04-16 11:39:41 +02:00
5f11dfcdc5 Implement deterministic simulation spine 2026-04-16 11:29:41 +02:00
8f5721462d Add sim project to sln 2026-04-16 11:08:04 +02:00
060552a0ce delete artifacts 2026-04-16 11:02:36 +02:00
82f1b4c2a7 delete artifacts 2026-04-16 11:01:12 +02:00
fc654f599d Add simulation project scaffold 2026-04-16 10:56:01 +02:00
147 changed files with 7143 additions and 29070 deletions

8
.gitignore vendored
View File

@@ -1,4 +1,12 @@
# Godot 4+ specific ignores # Godot 4+ specific ignores
.godot/ .godot/
.idea .idea
*.suo
*.user
_ReSharper.*
/android/ /android/
bin
obj
packages
coverage.cobertura.xml
coverage.json

View File

@@ -4,11 +4,9 @@
This repository should be organized as a Godot 4 .NET project from the root: This repository should be organized as a Godot 4 .NET project from the root:
- `project.godot` project entrypoint - `project.godot` project entrypoint
- `SideScrollerGame.csproj` and optional solution file
- `scenes/` playable scenes such as `scenes/player/Player.tscn` - `scenes/` playable scenes such as `scenes/player/Player.tscn`
- `scripts/` C# gameplay code paired with scenes - `scripts/` C# gameplay code paired with scenes
- `assets/` sprites, audio, fonts, and tile sets - `assets/` sprites, audio, fonts, and tile sets
- `tests/SideScrollerGame.Tests/` .NET test project for engine-light logic
- `addons/` third-party Godot plugins when needed - `addons/` third-party Godot plugins when needed
- `build/` local export output; do not commit generated files unless release workflow requires it - `build/` local export output; do not commit generated files unless release workflow requires it
@@ -19,14 +17,12 @@ Run commands from the repository root. On Windows PowerShell, use the repo-local
- `.\godot --editor --path .` opens the project in the editor - `.\godot --editor --path .` opens the project in the editor
- `.\godot --headless --path . --build-solutions` builds generated C# project files through Godot - `.\godot --headless --path . --build-solutions` builds generated C# project files through Godot
- `dotnet build` compiles the game assemblies and catches regular C# build errors - `.\godot --headless --path . --export-release "Windows Desktop" ./build/zfxaction26_1.exe` exports a desktop build when presets are configured
- `dotnet test tests/SideScrollerGame.Tests` runs automated unit tests
- `.\godot --headless --path . --export-release "Windows Desktop" ./build/SideScrollerGame.exe` exports a desktop build when presets are configured
Document any custom wrapper scripts in `README.md` if the team adds them later. Document any custom wrapper scripts in `README.md` if the team adds them later.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
Use 4 spaces for indentation, nullable reference types, and one primary public type per file. Keep Godot node scripts thin and move reusable gameplay rules into plain C# classes that do not depend on scene state. For C#, use 4 spaces for indentation, nullable reference types, and one primary public type per file. Keep Godot node scripts thin and move reusable gameplay rules into plain C# classes that do not depend on scene state.
- `PascalCase` for classes, methods, properties, scene files, and C# scripts: `PlayerController`, `PlayerController.cs` - `PascalCase` for classes, methods, properties, scene files, and C# scripts: `PlayerController`, `PlayerController.cs`
- `m_PascalCase` for private fields - `m_PascalCase` for private fields
@@ -45,19 +41,13 @@ Keep a strict element order inside of types:
- Events - Events
- Fields - Fields
## Testing Guidelines
Use a .NET test project under `tests/` and mirror gameplay areas, for example `tests/SideScrollerGame.Tests/Player/PlayerControllerTests.cs`. Prefer xUnit-style or NUnit-style tests for movement math, combat rules, save/load logic, and other engine-light systems.
Add regression tests for every gameplay bug fix when practical. If scene-level automation is added later, keep it separate from fast unit tests.
## Working rules ## Working rules
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.6.0. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative. - Follow operating system specific rules, either AGENTS.windows.md or AGENTS.linux.md
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
- Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan. - Before beginning with the edit phase, always present a plan first. Only begin editing after the user approves the plan.
- Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan. - Don't make assumptions in the plan. If necessary, ask all clarifying questions before presenting the final plan.
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary. - After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
- After every iteration, run `jb cleanupcode --build=False $file1;$file2;...` for every file you touched. - After every iteration, before committing, run `jb cleanupcode --build=False $file1;$file2;...` for every C# file you touched.
- After every iteration, if there's a relevant documentation for the current task, update it according to the change. - After every iteration, if there's a relevant documentation for the current task, update it according to the change.
- Update the wording of touched concerns instead of introducing incremental change reports - Update the wording of touched concerns instead of introducing incremental change reports
- The documentation should always represent the current state in its entirety and not derail into a historical development log. - The documentation should always represent the current state in its entirety and not derail into a historical development log.
@@ -70,10 +60,10 @@ Add regression tests for every gameplay bug fix when practical. If scene-level a
## Output generation ## Output generation
### User ### User
- For the user, you talk like a caveman. Speak only short grunts. Communicate in english. - For the user, you talk like a caveman. Speak only short grunts, conveying as much information in as few words as possible. Communicate in english.
- Give no explanations unless explicitly asked. - Give no explanations unless explicitly asked.
- No fillers like 'happy to help'. Do task first. Show result. Stop. - No fillers like 'happy to help' or 'grunt'. Do task first. Show result. Stop.
- For tools: "Tool work." then output the result and words like "Problem.", "Plan?", "Done." - For tools: "Tool work." then output the result and words like "Question:", "Problem.", "Plan?", "Done."
- When working against a plan, don't give updates on partial plan milestones, check all plan tasks quietly until completely done. - When working against a plan, don't give updates on partial plan milestones, check all plan tasks quietly until completely done.
### Reasoning ### Reasoning

4
AGENTS.windows.md Normal file
View File

@@ -0,0 +1,4 @@
# Windows-specific rules
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.6.0. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.

624
CODE.md Normal file
View File

@@ -0,0 +1,624 @@
# Side-Scrolling Shooter Implementation Plan
This document describes the implementation order for the game jam vertical slice and the supporting tools needed to test each feature quickly while building. `DESIGN.md` remains the system design source. This file is the coding plan for turning that design into a playable Godot 4 .NET game with fast iteration loops.
The guiding rule is: every feature should be reachable in seconds from a test scene, debug shortcut, or timeline jump. A one-month jam cannot afford long manual setup before testing a weapon, cluster, boss phase, pickup, difficulty setting, or mission transition.
## Working Principles
- Build one thin playable path first, then widen systems.
- Keep gameplay rules in plain C# classes where possible.
- Keep Godot node scripts focused on scene wiring, input, visuals, audio, and signals.
- Make definitions data-driven before adding many content variants.
- Add debug controls with the feature, not later.
- Make every new system testable in isolation and inside the real mission loop.
- Prefer deterministic test data and seeded randomness.
- Commit after each small, working slice.
## Target Vertical Slice
The first complete slice should contain:
- A bootable Godot .NET project.
- A menu with start, controls, and difficulty selection.
- A shop where the player chooses one special weapon.
- One mission with intro, gameplay, boss intro, boss fight, boss destruction, outro, and highscore/end state.
- One fixed camera path with speed changes.
- One repeating background layer and one repeating foreground layer.
- One interactive layer with enemy spawns and boss content.
- One hero with movement, shield charges, retries, points, level, primary weapon slots, secondary weapon, special weapon ammo, and squadron mates.
- One primary weapon, one secondary weapon, and one special weapon.
- Two normal enemy types: one serial behavior and one parallel movement/firing behavior.
- One enemy cluster with completion reward.
- One point collectible and one squadron mate collectible.
- One boss placeholder with phases and destruction sequence.
- Debug tools that can jump directly to the mission, cluster, weapon range, boss, and end states.
## Folder Layout
Use this layout unless the project already establishes a better local convention:
- `scenes/bootstrap/` for startup and root scene.
- `scenes/menu/` for menu, controls, difficulty selection, and highscore screens.
- `scenes/shop/` for pre-mission special weapon selection.
- `scenes/mission/` for the mission runner and test mission scene.
- `scenes/level/` for parallax, camera path, and interactive layer scenes.
- `scenes/hero/` for hero and squadron mate scenes.
- `scenes/enemies/` for enemy and boss scenes.
- `scenes/weapons/` for projectile, beam, explosion, and special weapon scenes.
- `scenes/collectibles/` for pickup scenes.
- `scenes/debug/` for test harness scenes and debug UI.
- `scripts/game/` for run state, scene flow, and services.
- `scripts/content/` for definition classes and sample data.
- `scripts/level/` for camera path, parallax, timeline, and mission runner code.
- `scripts/hero/` for hero controller, inventory, level, shields, retries, and squadron mates.
- `scripts/weapons/` for weapon runtime, projectile runtime, targeting, and collision rules.
- `scripts/enemies/` for enemy actor, behavior tracks, clusters, and boss phases.
- `scripts/collectibles/` for pickup runtime and pickup application rules.
- `scripts/debug/` for debug overlay, shortcuts, spawners, and test harnesses.
- `tests/` for C# rule tests and any Godot scene smoke tests.
## Slice 1: Project Shell
Create the Godot .NET project shell, root scene, and buildable solution before gameplay code.
Implement:
- `project.godot`.
- A root scene that can load menu, shop, mission, debug scenes, and end screens.
- Basic input actions for movement, primary fire, secondary fire, special fire, pause, debug overlay, and quick restart.
- A tiny smoke scene that displays the hero placeholder and exits cleanly in headless mode.
- A build command path using `.\godot --headless --path . --build-solutions`.
Testing and iteration support:
- Add a `DebugBootMode` setting that can start directly in menu, mission, weapon range, cluster sandbox, boss sandbox, or end-state sandbox.
- Add one command-line or exported setting for deterministic seed.
- Add a visible build/version label in debug mode.
Acceptance:
- Godot opens the project.
- The C# solution builds.
- The root scene can load at least one playable scene and one debug scene.
## Slice 2: Core Data Definitions
Implement the definition layer before content grows. Definitions should be small plain C# types or Godot resources, whichever is faster to edit and safer to serialize.
Implement:
- `MissionDefinition`.
- `DifficultyDefinition`.
- `CameraPathDefinition`.
- `LevelLayerDefinition`.
- `EnemyTypeDefinition`.
- `EnemyBehaviorDefinition`.
- `EnemyClusterDefinition`.
- `CollectibleDefinition`.
- `WeaponDefinition`.
- `SpecialWeaponDefinition`.
- `SquadronMateTypeDefinition`.
- Sample definitions for the first mission.
Testing and iteration support:
- Add a content registry that exposes definitions by stable id.
- Add a debug content browser that lists loaded missions, enemies, weapons, collectibles, difficulties, clusters, and boss phases.
- Add validation methods that report missing ids, invalid timings, invalid multipliers, empty weapon slots, and broken mission references.
Acceptance:
- The test mission loads from definitions.
- Invalid sample data produces clear validation errors before gameplay starts.
## Slice 3: Debug Foundation
Build shared debug tools early so every later feature can plug into them.
Implement:
- Toggleable debug overlay.
- Pause, resume, frame-step, and time-scale controls.
- Reload current scene.
- Restart current mission.
- Switch difficulty in test contexts.
- Set deterministic seed.
- Spawn actor by definition id.
- Jump to mission timeline marker.
- Toggle collision shapes and gameplay bounds.
- Toggle invulnerability.
- Toggle infinite special ammo.
- Toggle no enemy fire.
Testing and iteration support:
- Debug shortcuts should work with keyboard and clickable debug UI.
- Every debug command should be callable from C# so tests and future automation can use the same hooks.
- Debug overlay should show current scene, mission time, camera progress, difficulty, hero state, enemy count, projectile count, cluster id, boss phase, and random seed.
Acceptance:
- A developer can enter a test scene, pause time, spawn a target, fire, reset, and repeat without returning to the editor.
## Slice 4: Hero Runtime
Implement the hero as the first real gameplay actor.
Implement:
- Camera-relative movement bounds.
- Movement input.
- Shield charges.
- Retry count.
- Damage and death rules.
- Points and level-up thresholds.
- Primary weapon slots and current slot selection.
- Secondary weapon slot.
- Special weapon slot and ammo.
- Mission-persistent inventory state.
- Death state that clears collectibles and squadron mates.
Testing and iteration support:
- Add a hero sandbox scene with bounds visible.
- Add debug controls for damage, heal, kill, add points, set level, add shield, remove shield, set retries, and clear inventory.
- Add a quick toggle for invulnerability.
- Add a small text panel showing all hero state changes.
Acceptance:
- The hero can move inside bounds.
- Damage, shields, retries, death, rebirth, points, and level-up can be exercised without enemies.
## Slice 5: Weapon Test Range
Implement projectile infrastructure and the first weapon set in a dedicated range before wiring the full mission.
Implement:
- Projectile actor.
- Projectile ownership.
- Projectile lifetime.
- Projectile collision categories.
- Primary fire loop.
- Secondary fire loop.
- Special fire loop.
- Basic ballistic projectile.
- Basic forward secondary projectile.
- One special weapon projectile.
- Dummy enemy target.
- Dummy enemy projectile.
Testing and iteration support:
- Create `WeaponRange` scene with hero, dummy targets, enemy projectile emitters, and clear reset zones.
- Add debug controls to switch primary weapon, secondary weapon, special weapon, hero level, squadron count, and ammo.
- Add spawn buttons for stationary target, moving target, enemy projectile stream, and dense projectile wall.
- Add damage numbers or hit counters in debug mode.
- Add projectile count and collision count to overlay.
Acceptance:
- The player can test each weapon behavior within seconds.
- Projectile consumption rules can be verified in the range before enemies exist.
## Slice 6: Collectibles
Implement pickups after hero inventory and before enemy drops.
Implement:
- Collectible actor.
- Pickup collision.
- Pickup application service.
- Points pickup.
- Primary weapon pickup rule.
- Secondary weapon pickup rule.
- Shield charge pickup.
- Special ammo pickup.
- Squadron mate pickup rule.
- Clear screen pickup hook.
Testing and iteration support:
- Add pickup injector controls in the hero sandbox and mission debug overlay.
- Add a collectible test lane where pickups drift toward the hero.
- Add one-button spawn for every collectible type.
- Show before/after inventory state when a pickup is collected.
Acceptance:
- Every pickup type can be spawned and collected without enemy setup.
- Inventory state updates are visible immediately.
## Slice 7: Squadron Mates
Implement squadron mates once hero firing and pickups exist.
Implement:
- Mate spawning and despawning.
- Maximum mate count.
- Type switching.
- Hug formation.
- Orbit formation.
- Line formation.
- V formation.
- Follow formation.
- Mate primary and secondary firing.
- Enemy projectile consumption by mates.
Testing and iteration support:
- Extend `WeaponRange` with mate controls.
- Add controls for mate count, mate type, spacing, orbit speed, and follow target.
- Add projectile wall emitters to test consumption.
- Add formation ghost markers in debug mode.
Acceptance:
- All formations can be switched live.
- Mate projectile consumption and firing can be tested without a mission.
## Slice 8: Enemy Behavior System
Implement enemies and behavior tracks after weapons can damage targets.
Implement:
- Enemy actor with composed sprite parts.
- Health and damage handling.
- Score value.
- Drop table hook.
- Serial behavior track runner.
- Parallel behavior track runner.
- Movement path event.
- Orientation path event.
- Wait event.
- Fire projectile event.
- Enable/disable hitbox event.
- Spawn child event.
- VFX/SFX hook.
Testing and iteration support:
- Create `EnemySandbox` scene.
- Add dropdown or shortcuts to spawn enemy by type id.
- Add controls to restart behavior, scrub behavior time, slow time, and freeze movement while firing continues.
- Draw behavior path, current target point, fire mounts, hitboxes, and hurtboxes.
- Show behavior track names and active event names in overlay.
Acceptance:
- One enemy can run a serial movement sequence.
- One enemy can move and fire in parallel.
- Both can be killed by hero weapons in the sandbox.
## Slice 9: Enemy Clusters
Implement cluster scheduling once individual enemies work.
Implement:
- Cluster runtime state.
- Spawn schedule.
- Spawn anchors relative to screen, camera, world path, or marker.
- Cluster membership tracking.
- Destroyed, escaped, and resolved states.
- Completion reward.
- Difficulty-aware spawn interval multiplier.
Testing and iteration support:
- Create `ClusterSandbox` scene.
- Add cluster replay button.
- Add jump to cluster by id.
- Add one-button kill all, escape all, and complete all.
- Add timeline scrub and time-scale controls.
- Show cluster state, remaining enemies, escaped enemies, next spawn time, and reward status.
Acceptance:
- A cluster can be played repeatedly without restarting the full mission.
- Reward behavior can be verified for destroyed and escaped enemies.
## Slice 10: Level Runner
Implement the real mission stage after hero, weapons, enemies, clusters, and pickups can be tested alone.
Implement:
- Mission timeline.
- Fixed camera path.
- Speed curve.
- Camera-relative hero bounds.
- Background parallax group.
- Foreground parallax group.
- Interactive layer.
- Layer repetition.
- Layer transition hook.
- Spawn markers.
- Mission timeline markers.
- Music cue hooks.
Testing and iteration support:
- Add `TestMission` with short timeline sections: intro, normal cluster, transition, second cluster, boss marker, outro marker.
- Add jump controls for every timeline marker.
- Add camera speed override and freeze camera.
- Draw camera path, current progress, spawn markers, layer bounds, and hero bounds.
- Add a marker list UI for quick jumps.
Acceptance:
- The test mission can be played normally or jumped to any section.
- Parallax, camera speed, enemy spawns, and hero bounds remain coherent after jumps.
## Slice 11: Mission Flow
Wire the playable loop after the mission stage works in isolation.
Implement:
- Logo placeholder.
- Skippable intro choreography.
- Menu.
- Controls screen.
- Difficulty selection.
- Shop.
- Mission intro.
- Fade to gameplay.
- Boss intro transition.
- Mission outro.
- Rebirth retry state.
- Game over state.
- Normal victory state.
- Secret victory hook.
Testing and iteration support:
- Add debug boot modes for every flow state.
- Add skip choreography shortcut.
- Add force result shortcuts: retry, game over, normal victory, secret victory.
- Add a flow state overlay showing current state and allowed transitions.
- Add quick start with selected difficulty and selected special weapon.
Acceptance:
- The full route from menu to mission to end state works.
- Every choreographed or transitional state can be skipped or booted directly for testing.
## Slice 12: Boss Slice
Implement boss behavior after mission flow can reach the boss marker.
Implement:
- Boss actor based on enemy behavior runtime.
- Boss composed parts.
- Boss phase controller.
- Phase health or shared health.
- Boss projectile pattern hook.
- Boss intro.
- Boss destruction VFX hook.
- Boss music cue hook.
- Boss reward and mission completion trigger.
Testing and iteration support:
- Create `BossSandbox` scene.
- Add phase jump controls.
- Add damage boss, kill part, kill phase, and kill boss buttons.
- Add boss attack pattern selector.
- Add invulnerable boss toggle for pattern testing.
- Show boss health, part health, active phase, active pattern, and next phase trigger.
Acceptance:
- Boss phases can be tested without replaying the mission.
- The real mission can jump directly to boss intro and continue through victory.
## Slice 13: Difficulty
Implement difficulty after baseline systems exist, then apply it consistently.
Implement:
- Difficulty selection before run start.
- Active difficulty stored in run state.
- Difficulty applied to hero starting shields and retries.
- Difficulty applied to enemy health.
- Difficulty applied to enemy projectile speed.
- Difficulty applied to enemy fire cadence.
- Difficulty applied to cluster spawn intervals.
- Difficulty applied to boss health and phase timing.
- Difficulty applied to collectible drop rates.
- Difficulty applied to special weapon ammo.
- Difficulty applied to score.
- Optional mission overrides by difficulty.
Testing and iteration support:
- Add difficulty switcher to all sandbox scenes.
- Show active modifiers in debug overlay.
- Add compare mode that restarts the same cluster or boss phase under another difficulty with the same seed.
- Add validation that every difficulty has visible menu copy and all required multipliers.
Acceptance:
- A developer can replay the same cluster, enemy, weapon range, or boss phase on each difficulty quickly.
- The menu clearly communicates the active difficulty.
## Slice 14: Highscore And Run End
Implement end states once scoring and difficulty are stable.
Implement:
- Score accumulation.
- Score multiplier from difficulty.
- Highscore entry.
- Stored score table.
- Mission reached.
- Difficulty in score record.
- Victory type.
- Retry and game over flow.
Testing and iteration support:
- Add debug buttons to add score, clear local highscores, force highscore entry, and force each victory type.
- Add highscore sandbox with seeded sample scores.
- Show score event log in debug overlay.
Acceptance:
- Scores are recorded with difficulty and mission reached.
- Retry, game over, normal victory, and secret victory can each be reached directly and through gameplay.
## Slice 15: Content Expansion Pass
After the vertical slice works, expand content only where the systems are already easy to test.
Implement:
- More primary weapon variants.
- More secondary weapon variants.
- Remaining special weapons.
- More enemy behavior events if needed.
- More clusters.
- More layer transition styles.
- More boss attacks.
- More collectible visuals.
- More mission definitions.
Testing and iteration support:
- Every new weapon must be added to `WeaponRange`.
- Every new enemy must be added to `EnemySandbox`.
- Every new cluster must be added to `ClusterSandbox`.
- Every new boss attack must be reachable in `BossSandbox`.
- Every new mission section must get a timeline marker.
Acceptance:
- New content can be tested from a sandbox before being placed in the mission.
## Automated Tests
Use automated tests for pure rules and brittle integration points. Do not rely on automation for every visual or feel decision during the jam.
Implement C# tests for:
- Definition validation.
- Difficulty modifier application.
- Hero shield, retry, death, and rebirth rules.
- Point thresholds and level-up.
- Primary weapon pickup replacement rules.
- Secondary weapon replacement rules.
- Special ammo pickup rules.
- Squadron mate type and count rules.
- Cluster destroyed, escaped, and completed states.
- Score multiplier application.
- Highscore sorting.
Implement scene smoke tests when practical:
- Root scene loads.
- Menu loads.
- Shop confirms selection.
- Test mission loads.
- Weapon range loads.
- Enemy sandbox loads.
- Cluster sandbox loads.
- Boss sandbox loads.
The fast local loop should be:
1. Run the smallest relevant C# rule tests.
2. Run the relevant sandbox scene.
3. Use debug controls to test edge cases.
4. Run full build before commit.
## Manual Debug Recipes
Each feature should have a short repeatable manual recipe in this file or a later `DEBUG.md`.
Examples:
- Weapon collision: boot `WeaponRange`, spawn projectile wall, set primary to projectile-consuming shot, hold primary fire, watch consumed count.
- Cluster reward: boot `ClusterSandbox`, select cluster id, replay, kill all enemies, verify reward; replay, let one escape, verify no reward.
- Boss phase: boot `BossSandbox`, jump to phase 2, enable invulnerable hero, run pattern for 30 seconds, then kill phase.
- Mission transition: boot `TestMission`, jump to layer transition marker, freeze camera, step frames, verify parallax swap.
- Difficulty compare: boot `ClusterSandbox`, select Normal, replay; switch Hard with same seed, replay; compare spawn timing and projectile speed.
## Debug Controls To Keep Through The Jam
Keep these controls available in non-release builds:
- Toggle debug overlay.
- Reload scene.
- Restart run.
- Pause.
- Frame step.
- Time scale 0.25x, 0.5x, 1x, 2x, 4x.
- Toggle invulnerability.
- Toggle infinite special ammo.
- Toggle no enemy fire.
- Add points.
- Damage hero.
- Kill hero.
- Rebirth.
- Spawn collectible by id.
- Spawn enemy by id.
- Spawn enemy projectile stream.
- Replay cluster.
- Jump to mission marker.
- Jump to boss phase.
- Kill boss phase.
- Force mission victory.
- Force game over.
Remove or hide them only for release exports.
## Commit Cadence
Each commit should leave the project buildable and should include one small useful increment. Good commit boundaries:
- Project shell builds.
- Debug overlay boots.
- Definitions validate.
- Hero sandbox works.
- Weapon range works.
- Collectibles work.
- Squadron mates work.
- Enemy sandbox works.
- Cluster sandbox works.
- Test mission works.
- Mission flow works.
- Boss sandbox works.
- Difficulty works.
- Highscore works.
Before each commit:
1. Run relevant rule tests.
2. Run relevant scene manually or through a smoke test.
3. Run `.\godot --headless --path . --build-solutions`.
4. Run `jb cleanupcode --build=False` for touched C# files.
5. Update documentation for changed behavior.
## Priority Rules During The Jam
If time gets tight:
- Keep the full mission loop working.
- Keep one polished weapon better than many half-working weapons.
- Keep debug jumps and sandboxes working.
- Prefer data tweaks over new systems.
- Cut optional special weapons before cutting boss, shop, difficulty, or retry flow.
- Cut secret victory implementation before cutting normal victory and highscore.
- Cut additional enemy types before cutting cluster replay tooling.
- Cut visual polish only after preserving readable collisions and feedback.
The goal is a complete, testable side-scrolling shooter foundation that can be themed quickly when the jam topic is known.

560
DESIGN.md Normal file
View File

@@ -0,0 +1,560 @@
# Side-Scrolling Shooter Systems Design
This document captures systems that can be prepared before the game jam topic is known. It avoids theme, story, enemy names, weapon names, and visual commitments. All content-specific choices should live in data files, scene resources, or exported Godot properties so the final topic can reskin and rebalance the game without changing core systems.
## Design Goals
- Keep gameplay systems modular and data-driven.
- Separate content from behavior wherever practical.
- Allow each mission to define its own visual layers, music, enemy waves, boss, shop offer, rewards, and choreographed sequences.
- Support a fixed side-scrolling camera path with authored speed changes.
- Preserve enough flexibility for a serious, comic, abstract, horror, cute, mechanical, biological, fantasy, or surreal jam theme.
## Project Organization
Use the repository root as a Godot 4 .NET project.
- `project.godot` is the Godot project entrypoint.
- `scenes/` contains playable and reusable Godot scenes.
- `scripts/` contains C# scripts paired with scenes.
- `assets/` contains sprites, audio, fonts, tile sets, particles, and animation data.
- `addons/` contains third-party Godot plugins if needed.
- `build/` contains local export output and should stay uncommitted unless a release workflow requires it.
Recommended scene and script pairs:
- `scenes/game/GameRoot.tscn` with `scripts/game/GameRoot.cs`
- `scenes/level/LevelRunner.tscn` with `scripts/level/LevelRunner.cs`
- `scenes/hero/Hero.tscn` with `scripts/hero/HeroController.cs`
- `scenes/enemies/EnemyActor.tscn` with `scripts/enemies/EnemyActor.cs`
- `scenes/weapons/Projectile.tscn` with `scripts/weapons/Projectile.cs`
- `scenes/collectibles/Collectible.tscn` with `scripts/collectibles/Collectible.cs`
- `scenes/ui/Shop.tscn` with `scripts/ui/ShopController.cs`
## Data-Driven Content
Prefer Godot resources or plain C# definition classes for tunable game content. The final jam topic should mainly add or edit these definitions.
- `MissionDefinition`: level route, parallax sets, encounter schedule, boss, music, intro/outro choreographies, victory type rules, shop options.
- `LevelLayerDefinition`: texture set, scroll factor, repeat mode, transition rule, visibility window, optional animation.
- `CameraPathDefinition`: path points, speed curve, easing, gameplay bounds, boss lock behavior.
- `EnemyTypeDefinition`: composed sprite parts, health, collision bounds, score, behaviors, projectile patterns, collectible drop table.
- `EnemyClusterDefinition`: enemy type references, spawn timings, spawn anchors, cluster completion reward.
- `CollectibleDefinition`: category, pickup behavior, value, visuals, sound, persistence rule.
- `WeaponDefinition`: firing cadence, projectile count, damage, speed, collision behavior, seeking rules, level scaling.
- `SquadronMateTypeDefinition`: formation rule, spacing, movement smoothing, targeting behavior, visuals.
- `SpecialWeaponDefinition`: initial ammo, ammo pickup amount, projectile behavior, damage rule, invulnerability or pull effects.
- `DifficultyDefinition`: named difficulty preset, gameplay multipliers, survival rules, score modifiers, content overrides.
## Level System
The level is a timeline-driven side-scrolling space. The camera follows a fixed authored path while the world layers, enemy spawns, and choreographies react to mission time and camera progress.
### Layers
Use separate layer groups:
- Background parallax layers without collision.
- Interactive elements layer with enemies, hazards, boss parts, destructible objects, collectible spawns, and terrain used by special weapons.
- Foreground parallax layers without collision.
Each parallax layer should support:
- Independent scroll factor.
- Repeating textures or scene chunks.
- Optional animation.
- Optional transition into a different layer set as the mission progresses.
- Visibility windows based on mission progress.
Layer transitions should support crossfade, slide-in replacement, hard cut during choreo, or hidden swap behind foreground cover. The transition type should be data, not hardcoded.
### Repeating Level Content
Parallax layers may repeat indefinitely. Interactive content should not repeat blindly; it should be scheduled by the mission definition so enemy clusters and pickups stay intentional.
Reusable repeating layer options:
- Texture-based repeat for simple skies, stars, clouds, tunnels, smoke, speed lines, or abstract fields.
- Chunk-based repeat for large authored sections.
- Procedural variation by selecting from a small set of compatible chunks.
### Camera Path
The player does not control camera movement. The mission controls:
- Path position over time.
- Speed changes.
- Gameplay bounds around the camera.
- Slowdowns or stops for boss intros.
- Acceleration during escape or chase sections.
- Transition windows for layer changes and music cues.
The hero should move inside a camera-relative play area. Enemy spawns can be anchored to the camera, world path, screen edges, or authored scene markers.
## Enemy Types
An enemy type is a reusable actor definition. It may be a free-moving spawned enemy or a level-attached interactive element.
Enemy types consist of composed sprite parts. Each part may define:
- Local offset and orientation.
- Sprite or animation.
- Optional hitbox.
- Optional hurtbox.
- Optional weapon mount.
- Optional destruction effect.
- Optional behavior track.
The whole enemy owns shared state:
- Health.
- Score value.
- Cluster membership.
- Drop table.
- Death behavior.
- Whether it is free-moving or attached to the level layer.
### Enemy Behaviors
A behavior is one serial sequence or many parallel sequences of timed events.
Supported event categories:
- Traverse a position path.
- Traverse an orientation path.
- Wait.
- Fire projectile pattern.
- Change speed.
- Change animation.
- Enable or disable hitboxes.
- Spawn child enemy or projectile.
- Move relative to camera.
- Move relative to level path.
- Trigger VFX or SFX.
- Signal cluster state.
Behavior authoring should allow both:
- Serial tracks where event B starts after event A.
- Parallel tracks where movement, aiming, firing, animation, and part transforms run together.
This can be implemented as composable behavior nodes, C# definition data, Godot animation tracks, or a hybrid. The key rule is that content creators can assemble behavior without rewriting the enemy runtime.
## Enemy Clusters
An enemy cluster is a mission-scheduled encounter group. It contains several instances of one or more enemy types spawned at different intervals.
A cluster definition should include:
- Cluster id.
- Spawn schedule.
- Enemy type references.
- Spawn positions or anchors.
- Optional path references.
- Optional shared behavior parameters.
- Completion reward.
Destroying all enemies in a cluster awards points. Enemies that leave the play area without being destroyed should follow a cluster-specific rule:
- Count as escaped and prevent completion reward.
- Count as resolved and still allow completion reward.
- Respawn or loop until destroyed.
Default rule: escaped enemies prevent the cluster completion reward. This keeps cluster rewards meaningful, but the mission can override it for casual or cinematic waves.
## Collectibles
Collectibles are spawned by enemies, clusters, scripted mission events, hidden triggers, or boss phases. Pickups should use a shared collection interface so hero, inventory, score, and mission systems are not tightly coupled to pickup scenes.
Collectible categories:
- Points.
- Primary weapon.
- Secondary weapon.
- Clear screen.
- Shield charge.
- Special weapon ammunition.
- Squadron mate of a specific type.
Primary weapon pickup rule:
- Fill an empty primary weapon slot if one exists.
- Otherwise replace the currently selected primary weapon slot.
Secondary weapon pickup rule:
- Replace the current secondary weapon.
Clear screen pickup rule:
- Destroy or neutralize normal enemy projectiles.
- Damage or destroy eligible non-boss enemies.
- Avoid deleting bosses unless a specific boss phase says it can.
Persistent pickup rule:
- The hero keeps collectibles at mission end.
- The hero loses all collectibles on death.
Squadron mate rule:
- If the collectible matches the current type, increase mate count (capped at max)
- Otherwise change the current type
## Hero
The hero is the player's main actor. It owns mission-persistent inventory and mission-local survival state.
Hero state:
- Level.
- Points.
- Point thresholds for level up.
- Fixed number of primary weapon slots.
- Current primary weapon slot.
- Current secondary weapon.
- Current special weapon and ammo.
- Shield charges.
- Squadron mate list.
- Retry count.
Default survival rules:
- The hero starts with 3 shield charges.
- Getting hit consumes 1 shield charge.
- Leveling up adds 1 shield charge.
- A hit with 0 shield charges kills the hero.
- The hero starts with 3 retry counts.
- Rebirth consumes 1 retry.
- Game over occurs when retries are exhausted.
- Death removes all collectibles and squadron mates.
- Mission victory persists kept collectibles into the next mission.
Point thresholds should be configurable per game mode or mission set. The jam topic may change score pacing, so level-up curves should not be hardcoded.
## Squadron Mates
Squadron mates are support actors owned by the hero.
Rules:
- Maximum active squadron mates: 4.
- All active mates use the same type.
- The type is determined by the last collected squadron mate pickup.
- Mates maintain relative position according to type.
- Mates collide with and consume enemy projectiles.
- Mates take no damage from consuming enemy projectiles.
Changing squadron mate type should preserve count and replace formation behavior. The visual style can change with the final topic.
### Squadron Mate Types
Hug:
- Mates stay close together on the hero's nose.
- Useful for high forward damage and focused defense.
Orbit:
- Mates orbit around the hero.
- Mate id determines phase offset.
- Useful for defensive coverage.
Line-Formation:
- Mates form equidistant positions above and below the hero.
- Useful for vertical coverage.
V-Formation:
- Mates form equidistant positions behind and above or behind and below the hero.
- Useful for tight maneuvers.
Follow:
- Mates fly toward the front of the oldest enemy on screen.
- Useful for aggressive targeting.
## Hero And Squadron Firing
The hero and all squadron mates:
- Fire the current primary weapon forward.
- Fire the secondary weapon in its configured direction.
Firing ownership matters for balance. Each shot should know whether it came from the hero or a squadron mate so damage, effects, score credit, and visual scale can be tuned separately.
## Primary Weapons
Primary weapons have infinite ammo and are influenced by hero level.
Primary weapon properties:
- Ballistic many-fast-weak shots.
- Ballistic few-slow-strong shots.
- Forward-only projectiles.
- Seeking projectiles that target the nearest enemy.
- Laser beams that appear and disappear while filling an entire row.
- Grenade clusters that launch fast, slow down, then create a large explosion when stopped.
- Projectile-consuming shots that collide with and consume enemy projectiles.
Primary weapon level scaling may affect:
- Damage.
- Fire cadence.
- Projectile count.
- Projectile spread.
- Projectile size.
- Seeking strength.
- Projectile lifetime.
- Ability to consume enemy projectiles.
## Secondary Weapons
Secondary weapons have infinite ammo and always fire from the hero and squadron mates. Their projectiles do not collide with enemy projectiles.
Secondary weapon types:
- Vertical: fire upward when on the top half of the screen, or downward when on the bottom half.
- Diagonal: fire toward the positive diagonal when on the top half of the screen, or toward the negative diagonal when on the bottom half.
- Backward: fire backward.
Secondary weapons should support additional projectile counts depending on type. The final topic can turn these into exhaust blasts, magic sparks, drones, thrown tools, sound waves, or any other theme.
## Special Weapons
Special weapons are chosen in the shop before a mission. They have initial ammo and may be replenished by collectibles.
Special weapon types:
- Bomb: gravity affects it; it explodes once on impact; lots of ammo.
- Crawler: gravity affects it; it crawls forward following terrain contour; it explodes once on enemy impact; moderate ammo.
- Napalm: gravity affects it; it creates a huge horizontal spread and 1 second burn on impact; moderate ammo.
- Black Hole: grants 3 seconds of invulnerability, pulls enemies which are destroyed upon player impact; few ammo.
## Enemy Projectiles
Enemy offensive objects should be separated by gameplay role:
- Enemy projectile: damages the hero, may be consumed by eligible primary weapons or squadron mates without damaging them.
- Enemy hazard: part of interactive level content, may damage the hero and may ignore projectile-clear effects.
This separation allows the final topic to change visuals without changing collision rules.
## Shop
The shop appears before each mission. Its required function is choosing one special weapon type for the next mission.
The shop can later support:
- Previewing ammo count.
- Showing mission hint text.
- Showing currently kept collectibles.
- Locking special weapons that do not fit the mission.
The first implementation should stay small: choose one special weapon, confirm, enter mission intro.
## Game Loop
Top-level flow:
1. Logo.
2. Intro choreography, skippable.
3. Menu with start and controls.
4. Mission loop.
5. Rebirth retry, game over, normal victory, or secret victory choreography.
6. Highscore.
Choreography means an authored non-interactive sequence. It may move actors, play animations, change music, show text, or transition scenes. Choreography should be skippable where appropriate.
## Mission Loop
Per-mission flow:
1. Show next mission details and open shop for choosing one special weapon type for the next mission.
2. Queue mission-specific music.
3. Play mission-specific intro choreography.
4. Fade to actual gameplay.
5. Run camera path, level timeline, enemy clusters, collectibles, and player control.
6. Queue boss music.
7. Play boss-specific intro choreography.
8. Run boss fight.
9. Mute music and play boss destruction VFX.
10. Queue victory music.
11. Play generic mission outro choreography.
12. Fade out.
Mission results should determine:
- Continue to next mission.
- Trigger rebirth retry.
- Trigger game over.
- Trigger normal victory.
- Trigger secret victory.
- Submit or display highscore.
## Bosses
Bosses are specialized enemy clusters with stronger choreography and phase control.
Boss design should support:
- Composed sprite parts.
- Separate health pools per part or shared health.
- Phase transitions.
- Destructible mounts.
- Attached projectiles or hazards.
- Boss-specific intro.
- Boss-specific destruction VFX.
- Boss music queue.
Bosses should use the same behavior event model as enemies where possible. Add boss-specific code only for phase orchestration that normal enemies cannot express cleanly.
## Collision Model
Recommended collision categories:
- Hero body.
- Squadron mate body.
- Hero projectile.
- Hero projectile that consumes enemy projectiles.
- Enemy body.
- Enemy weak point.
- Enemy projectile.
- Enemy particle.
- Interactive hazard.
- Collectible.
- Terrain contour for special weapons.
Keep parallax background and foreground layers collision-free.
## Scoring
Scoring sources:
- Point collectibles.
- Enemy destruction.
- Cluster completion.
- Boss phases.
- Mission victory.
- Secret victory.
The score system should publish events for UI, highscore, level-up thresholds, and mission results.
## Difficulty Levels
Difficulty should be selected before starting a run and then remain fixed until game over, normal victory, or secret victory. It should be represented by a `DifficultyDefinition` so the final jam topic can rebalance the game without rewriting missions.
Difficulty should affect systems through explicit multipliers and overrides:
- Enemy health multiplier.
- Enemy projectile speed multiplier.
- Enemy fire cadence multiplier.
- Enemy spawn density multiplier.
- Cluster spawn interval multiplier.
- Boss health multiplier.
- Boss phase timing multiplier.
- Hero starting shield charges.
- Hero retry count.
- Collectible drop rate multiplier.
- Special weapon initial ammo multiplier.
- Score multiplier.
- Clear screen pickup strength.
- Enemy escape rule override for cluster rewards.
Avoid making difficulty depend on hidden rules. The player should be able to understand the broad promise of each difficulty from the menu.
Difficulty should not change core controls, mission order, weapon identity, or story outcome. Secret victory may require a minimum difficulty if the final topic benefits from that, but the requirement should be visible before the run starts.
Difficulty-specific mission overrides can be used when multipliers are not enough. For example, a mission may add an extra enemy cluster on Hard and Expert, or replace a boss projectile pattern on Expert. These overrides should be optional and declared in mission data.
## Persistence
Persist only what needs to survive across missions or game sessions.
Mission-to-mission state:
- Hero level.
- Points.
- Primary weapon slots.
- Current primary weapon slot.
- Current secondary weapon.
- Current special weapon ammo if the game mode keeps it.
- Shield charges.
- Squadron mate count and type.
- Retry count.
Run-ending highscore state:
- Player name or initials.
- Score.
- Mission reached.
- Difficulty.
## Testing Strategy
For a one-month jam, keep tests focused on systems that can break content quickly.
High-value unit tests:
- Level-up thresholds add shield charges.
- Primary weapon pickup fills empty slot before replacing current slot.
- Secondary weapon pickup replaces current weapon.
- Squadron mate pickup changes type while preserving count up to 4.
- Death clears collectibles and consumes retry when available.
- Game over triggers when retry count is exhausted.
- Cluster reward is awarded only when completion rules are satisfied.
- Special weapon ammo pickups replenish the selected special weapon.
- Difficulty modifiers adjust enemy health, projectile speed, spawn intervals, shield charges, retries, and score.
- Difficulty-specific mission overrides are applied only for matching difficulties.
High-value play tests:
- Start game and enter first mission.
- Select each difficulty and verify visible run settings.
- Pick a special weapon in the shop.
- Complete a small enemy cluster and receive points.
- Collect each collectible type.
- Die with shield charges and without shield charges.
- Rebirth and game over.
- Reach boss intro, boss destruction VFX, and mission outro.
## Open Topic Customization Points
When the jam topic is announced, decide these late:
- Visual identity of hero, enemies, projectiles, collectibles, and UI.
- Names and flavor text.
- Mission count and mission themes.
- Enemy sprite composition style.
- Background and foreground layer art.
- Music style.
- Sound effect vocabulary.
- Exact weapon names.
- Special victory requirement.
- Score balance.
- Difficulty curve.
- Boss concepts.
Do not decide these early unless they help prove a system.
## First Implementation Slice
The first playable slice should be intentionally small:
1. One mission definition.
2. One repeating background layer.
3. One repeating foreground layer.
4. One fixed camera path with speed changes.
5. One hero with movement, shield charges, points, and primary fire.
6. One primary weapon.
7. One secondary weapon.
8. One special weapon selected through a minimal shop.
9. One enemy type with a serial behavior.
10. One enemy type with parallel movement and firing behavior.
11. One enemy cluster with completion reward.
12. One collectible for points.
13. One collectible for squadron mate.
14. One boss placeholder with intro and destruction sequence.
15. One mission outro and highscore entry.
This slice proves the game loop and leaves almost all theme content open.

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,386 +0,0 @@
#if DEBUG
#define RANGE_CHECK
#endif
using System;
using System.Runtime.CompilerServices;
namespace MagmaEngine.Math;
public struct FixPoint16Long : IComparable, IComparable<FixPoint16Long>, IEquatable<FixPoint16Long>
{
public FixPoint16Long(FixPoint16Long other)
{
m_Value = other.m_Value;
}
public FixPoint16Long(int value)
{
m_Value = (long)value << c_Shift;
}
public FixPoint16Long(long value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Long to FixPoint argument out of range: {value}");
}
#endif
m_Value = value << c_Shift;
}
public FixPoint16Long(double value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Double to FixPoint argument out of range: {value}");
}
#endif
if (value < 0.0)
{
m_Value = (int)((value * c_Multiplier) - 0.5);
}
else
{
m_Value = (int)((value * c_Multiplier) + 0.5);
}
}
public FixPoint16Long(float value)
{
#if RANGE_CHECK
if (value < c_IntegerMin || value > c_IntegerMax)
{
throw new ArithmeticException($"Single to FixPoint argument out of range: {value}");
}
#endif
if (value < 0.0f)
{
m_Value = (int)((value * c_MultiplierFloat) - 0.5f);
}
else
{
m_Value = (int)((value * c_MultiplierFloat) + 0.5f);
}
}
public long ToLongFloor()
{
return m_Value >> c_Shift;
}
public long ToLongCeil()
{
return (m_Value + c_FractionMask) >> c_Shift;
}
public long ToLongRound()
{
if (m_Value < 0)
{
return -((-m_Value + c_Half) >> c_Shift);
}
return (m_Value + c_Half) >> c_Shift;
}
public long ToLong()
{
if (m_Value < 0)
{
return -(-m_Value >> c_Shift);
}
return m_Value >> c_Shift;
}
public double ToDouble()
{
return c_Divisor * m_Value;
}
public float ToFloat()
{
return c_DivisorFloat * m_Value;
}
public override string ToString()
{
return $"{ToDouble()}[0x{m_Value:x16}]";
}
public override int GetHashCode()
{
return m_Value.GetHashCode();
}
public int CompareTo(object? obj)
{
if (obj is not FixPoint16Long other)
return -1;
return m_Value.CompareTo(other.m_Value);
}
public int CompareTo(FixPoint16Long other)
{
return m_Value.CompareTo(other.m_Value);
}
public override bool Equals(object? obj)
{
if (obj == null)
{
return false;
}
return ((FixPoint16Long)obj).m_Value == m_Value;
}
public bool Equals(FixPoint16Long other)
{
return other.m_Value == m_Value;
}
public bool IsZero()
{
return m_Value == 0L;
}
public static bool operator ==(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value == b.m_Value;
}
public static bool operator !=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value != b.m_Value;
}
public static bool operator <(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value < b.m_Value;
}
public static bool operator >(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value > b.m_Value;
}
public static bool operator <=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value <= b.m_Value;
}
public static bool operator >=(FixPoint16Long a, FixPoint16Long b)
{
return a.m_Value >= b.m_Value;
}
public static FixPoint16Long operator <<(FixPoint16Long a, int shift)
{
return new() { m_Value = a.m_Value << shift };
}
public static FixPoint16Long operator >> (FixPoint16Long a, int shift)
{
return new() { m_Value = a.m_Value >> shift };
}
public static FixPoint16Long operator +(FixPoint16Long a, FixPoint16Long b)
{
return new() { m_Value = a.m_Value + b.m_Value };
}
public static FixPoint16Long operator -(FixPoint16Long a, FixPoint16Long b)
{
return new() { m_Value = a.m_Value - b.m_Value };
}
public static FixPoint16Long operator *(FixPoint16Long a, FixPoint16Long b)
{
Int128 bigA = a.m_Value;
Int128 bigB = b.m_Value;
var result = ((bigA * bigB) + c_Half) >> c_Shift;
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
{
throw new ArithmeticException($"Multiplication result out of range: {result}");
}
#endif
return new() { m_Value = (long)result };
}
public static FixPoint16Long operator /(FixPoint16Long a, FixPoint16Long b)
{
#if RANGE_CHECK
if (b.m_Value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
Int128 result;
if (((ulong)a.m_Value & 0x8000000000000000UL) == ((ulong)b.m_Value & 0x8000000000000000UL))
{
result = (((Int128)a.m_Value << c_Shift) + (b.m_Value / 2)) / b.m_Value;
}
else
{
result = (((Int128)a.m_Value << c_Shift) - (b.m_Value / 2)) / b.m_Value;
}
#if RANGE_CHECK
if (result < long.MinValue || result > long.MaxValue)
{
throw new ArithmeticException($"Division result out of range: {result}");
}
#endif
return new() { m_Value = (long)result };
}
public static FixPoint16Long operator -(FixPoint16Long a)
{
return new() { m_Value = -a.m_Value };
}
public static FixPoint16Long operator *(FixPoint16Long a, int value)
{
return new() { m_Value = a.m_Value * value };
}
public static FixPoint16Long operator /(FixPoint16Long a, int value)
{
#if RANGE_CHECK
if (value == 0)
{
throw new ArithmeticException("Divison by zero");
}
#endif
if (((a.m_Value >> 32) & 0x80000000) == (value & 0x80000000))
{
return new() { m_Value = (long)(((Int128)a.m_Value + (value / 2)) / value) };
}
return new() { m_Value = (long)(((Int128)a.m_Value - (value / 2)) / value) };
}
public static implicit operator FixPoint16Long(int value)
{
return new(value);
}
public static implicit operator FixPoint16Long(long value)
{
return new(value);
}
public static implicit operator FixPoint16Long(FixPoint16 value)
{
return new() { m_Value = value.m_Value };
}
public static explicit operator FixPoint16Long(double value)
{
return new(value);
}
public static explicit operator FixPoint16Long(float value)
{
return new(value);
}
public static explicit operator int(FixPoint16Long value)
{
return (int)value.ToLong();
}
public static explicit operator long(FixPoint16Long value)
{
return value.ToLong();
}
public static explicit operator double(FixPoint16Long value)
{
return value.ToDouble();
}
public static explicit operator float(FixPoint16Long value)
{
return value.ToFloat();
}
public static FixPoint16Long Floor(FixPoint16Long value)
{
return value.ToLongFloor();
}
public static FixPoint16Long Ceil(FixPoint16Long value)
{
return value.ToLongCeil();
}
public static FixPoint16Long Round(FixPoint16Long value)
{
return value.ToLongRound();
}
public static int Sign(FixPoint16Long value)
{
return System.Math.Sign(value.m_Value);
}
public static FixPoint16Long Abs(FixPoint16Long value)
{
return new() { m_Value = System.Math.Abs(value.m_Value) };
}
public static FixPoint16Long Min(FixPoint16Long value1, FixPoint16Long value2)
{
return new() { m_Value = System.Math.Min(value1.m_Value, value2.m_Value) };
}
public static FixPoint16Long Max(FixPoint16Long value1, FixPoint16Long value2)
{
return new() { m_Value = System.Math.Max(value1.m_Value, value2.m_Value) };
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16Long Length(FixPoint16Long a, FixPoint16Long b)
{
var aSquared = a.m_Value * a.m_Value;
var bSquared = b.m_Value * b.m_Value;
var value = aSquared + bSquared;
#if RANGE_CHECK
if (value < 0)
{
throw new ArithmeticException($"Length squared out of range: {value}");
}
#endif
return new() { m_Value = IntMath.Sqrt(value) };
}
private const int c_Shift = 16;
private const long c_IntegerMin = -140737488355327L;
private const long c_IntegerMax = 140737488355327L;
private const long c_Half = 32786L;
private const long c_FractionMask = 0x000000000000ffffL;
private const double c_Multiplier = 65536.0;
private const double c_Divisor = 1.0 / 65536.0;
private const float c_MultiplierFloat = 65536.0f;
private const float c_DivisorFloat = (float)c_Divisor;
public long m_Value;
public static readonly FixPoint16Long Zero = new() { m_Value = 0 };
public static readonly FixPoint16Long MinValue = new() { m_Value = long.MinValue };
public static readonly FixPoint16Long MaxValue = new() { m_Value = long.MaxValue };
public static readonly FixPoint16Long Epsilon = new() { m_Value = 1 };
public static readonly FixPoint16Long MinusEpsilon = new() { m_Value = -1 };
public static readonly FixPoint16Long One = new(1);
public static readonly FixPoint16Long MinusOne = new(-1);
public static readonly FixPoint16Long Half = new() { m_Value = One.m_Value / 2 };
public static readonly FixPoint16Long MinusHalf = -Half;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,69 +0,0 @@
namespace MagmaEngine.Math;
public static class FixPointUtil
{
public static long DistancePointToSegmentSquared(FixPointVector2 point, FixPointVector2 segmentStart, FixPointVector2 segmentEnd)
{
var segment = segmentEnd - segmentStart;
var toStart = point - segmentStart;
var segmentLengthSquared = FixPointVector2.DotLong(segment, segment);
if (segmentLengthSquared == 0L)
return FixPointVector2.DotLong(toStart, toStart);
var projection = FixPointVector2.DotLong(toStart, segment);
if (projection <= 0)
return FixPointVector2.DotLong(toStart, toStart);
FixPointVector2 toPointOnSegment;
if (projection >= segmentLengthSquared)
toPointOnSegment = point - segmentEnd;
else
toPointOnSegment = toStart - segment * FixPoint16.FromRational(projection, segmentLengthSquared);
return FixPointVector2.DotLong(toPointOnSegment, toPointOnSegment);
}
public static FixPoint16 DistancePointToSegment(FixPointVector2 point, FixPointVector2 segmentStart, FixPointVector2 segmentEnd)
{
return FixPoint16.SqrtLong(DistancePointToSegmentSquared(point, segmentStart, segmentEnd));
}
public static bool LineLineIntersection(FixPointVector2 line1Start, FixPointVector2 line1End, FixPointVector2 line2Start, FixPointVector2 line2End, out FixPointVector2 intersectionPoint)
{
intersectionPoint = default;
var s1 = line1End - line1Start;
var s2 = line2End - line2Start;
var det = (FixPoint16Long)(-s2.m_X) * s1.m_Y + (FixPoint16Long)(s1.m_X) * s2.m_Y;
if (FixPoint16Long.Abs(det) < s_IntersectionEpsilon)
return false;
var t = ((FixPoint16Long)s2.m_X * (line1Start.m_Y - line2Start.m_Y) - (FixPoint16Long)s2.m_Y * (line1Start .m_X - line2Start.m_X)) / det;
var px = line1Start.m_X + (t * s1.m_X);
var py = line1Start.m_Y + (t * s1.m_Y);
if (px.m_Value > s_LineIntersectionMax || px.m_Value < s_LineIntersectionMin || py.m_Value > s_LineIntersectionMax || py.m_Value < s_LineIntersectionMin)
return false;
intersectionPoint = new FixPointVector2(FixPoint16.FromValue(px.m_Value), FixPoint16.FromValue(py.m_Value));
return true;
}
public static FixPoint16 MultiplyClamped(FixPoint16 a, FixPoint16 b)
{
var iResult = (((long)a.m_Value * b.m_Value) + FixPoint16.c_Half) >> FixPoint16.c_Shift;
if (iResult < FixPoint16.c_LongMin)
iResult = FixPoint16.c_LongMin;
else if (iResult > FixPoint16.c_LongMax)
iResult = FixPoint16.c_LongMax;
return new() { m_Value = (int)iResult };
}
private static readonly FixPoint16 s_IntersectionEpsilon = FixPoint16.Epsilon * 30;
private static readonly long s_LineIntersectionMax = FixPoint16.c_LongMax / 4;
private static readonly long s_LineIntersectionMin = FixPoint16.c_LongMin / 4;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,993 +0,0 @@
using System;
using System.Diagnostics.Contracts;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace MagmaEngine.Math;
public struct SFixPointVector3 : IEquatable<SFixPointVector3>
{
/// <summary>
/// Initializes a new instance of FixPointVector3
/// </summary>
/// <param name="x">Initial value for the x-component of the vector.</param>
/// <param name="y">Initial value for the y-component of the vector.</param>
/// <param name="z">Initial value for the z-component of the vector.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(int x, int y, int z)
{
m_X = x;
m_Y = y;
m_Z = z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(FixPoint16 x, FixPoint16 y, FixPoint16 z)
{
m_X = x;
m_Y = y;
m_Z = z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(FixPoint16 x)
{
m_X = x;
m_Y = x;
m_Z = x;
}
public SFixPointVector3(float x, float y, float z)
{
m_X = new(x);
m_Y = new(y);
m_Z = new(z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public SFixPointVector3(Vector3 coord)
{
m_X = new(coord.X);
m_Y = new(coord.Y);
m_Z = new(coord.Z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static explicit operator SFixPointVector3(Vector3 coord)
{
return new(new(coord.X), new(coord.Y), new FixPoint16(coord.Z));
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static explicit operator Vector3(SFixPointVector3 coord)
{
return new(coord.m_X.ToFloat(), coord.m_Y.ToFloat(), coord.m_Z.ToFloat());
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public Vector3 ToVector3()
{
return new(m_X.ToFloat(), m_Y.ToFloat(), m_Z.ToFloat());
}
#region -- base overrides ---------------------------------------------
[Pure]
public override string ToString()
{
return $"({m_X.ToString()}, {m_Y.ToString()}, {m_Z.ToString()})";
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public bool Equals(SFixPointVector3 other)
{
return m_X == other.m_X && m_Y == other.m_Y && m_Z == other.m_Z;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public override bool Equals(object? obj)
{
if (obj is SFixPointVector3 fixPointVector3)
return Equals(fixPointVector3);
return false;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public override int GetHashCode()
{
return m_X.GetHashCode() + m_Y.GetHashCode() + m_Z.GetHashCode();
}
#endregion
#region -- static properties ------------------------------------------
public static readonly SFixPointVector3 s_Zero = new(0, 0, 0);
public static readonly SFixPointVector3 s_One = new(1, 1, 1);
public static readonly SFixPointVector3 s_UnitX = new(1, 0, 0);
public static readonly SFixPointVector3 s_UnitY = new(0, 1, 0);
public static readonly SFixPointVector3 s_UnitZ = new(0, 0, 1);
public static readonly SFixPointVector3 s_MaxValue = new(FixPoint16.MaxValue, FixPoint16.MaxValue, FixPoint16.MaxValue);
public static readonly SFixPointVector3 s_MinValue = new(FixPoint16.MinValue, FixPoint16.MinValue, FixPoint16.MinValue);
#endregion
#region -- public properties -----------------------------------------
public FixPoint16 this[int i]
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get
{
switch (i)
{
case 0: return m_X;
case 1: return m_Y;
case 2: return m_Z;
default: throw new ArgumentOutOfRangeException(nameof(i));
}
}
set
{
switch (i)
{
case 0:
m_X = value;
break;
case 1:
m_Y = value;
break;
case 2:
m_Z = value;
break;
default: throw new ArgumentOutOfRangeException(nameof(i));
}
}
}
/// <summary>
/// Returns a new normalized FixPointVector3 from the current vector.
/// </summary>
public SFixPointVector3 Normalized
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get
{
var length = Length();
if (!length.IsZero())
{
SFixPointVector3 result = new(m_X / length, m_Y / length, m_Z / length);
return result;
}
throw new InvalidOperationException("Error: can not normalize vector, the vector length is zero.");
}
}
public FixPointVector2 XY
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new(m_X, m_Y);
}
public FixPointVector2 XZ
{
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
get => new(m_X, m_Z);
}
#endregion
#region -- public methods ---------------------------------------------
/// <summary>
/// Calculates the length of the current vector.
/// </summary>
/// <returns>The Length of the current vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public FixPoint16 Length()
{
return FixPoint16.Length(m_X, m_Y, m_Z);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public int CompareLength(FixPoint16 length)
{
unchecked
{
return ((m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value)).CompareTo(length.m_Value * (long)length.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public long LengthSquaredLong()
{
unchecked
{
return (m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public int CompareLength(SFixPointVector3 other)
{
unchecked
{
return ((m_X.m_Value * (long)m_X.m_Value) + (m_Y.m_Value * (long)m_Y.m_Value) + (m_Z.m_Value * (long)m_Z.m_Value)).CompareTo(
(other.m_X.m_Value * (long)other.m_X.m_Value) + (other.m_Y.m_Value * (long)other.m_Y.m_Value) + (other.m_Z.m_Value * (long)other.m_Z.m_Value));
}
}
/// <summary>
/// Normalizes the current vector
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Normalize()
{
unchecked
{
var length = Length();
if (!length.IsZero())
{
m_X = m_X / length;
m_Y = m_Y / length;
m_Z = m_Z / length;
}
#if DEBUG
else
{
throw new("Error: can not normalize vector, the vector length is zero.");
}
#endif
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public bool IsZero()
{
return m_X.IsZero() && m_Y.IsZero() && m_Z.IsZero();
}
/// <summary>
/// Adds a given scalar value to each component of the current FixPointVector3.
/// </summary>
/// <param name="value">The scalar value</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(FixPoint16 value)
{
unchecked
{
m_X = m_X + value;
m_Y = m_Y + value;
m_Z = m_Z + value;
}
}
/// <summary>
/// Adds a given FixPointVector3 to the current FixPointVector3.
/// </summary>
/// <param name="other">The vector to be added.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Add(SFixPointVector3 other)
{
unchecked
{
m_X = m_X + other.m_X;
m_Y = m_Y + other.m_Y;
m_Z = m_Z + other.m_Z;
}
}
/// <summary>
/// Subtracts a scalar value from each component of the current FixPointVector3.
/// </summary>
/// <param name="value">The scalar value.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Subtract(FixPoint16 value)
{
unchecked
{
m_X = m_X - value;
m_Y = m_Y - value;
m_Z = m_Z - value;
}
}
/// <summary>
/// Subtracts a given FixPointVector3 from the current FixPointVector3.
/// </summary>
/// <param name="other">The vector to be subtracted.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Subtract(SFixPointVector3 other)
{
unchecked
{
m_X = m_X - other.m_X;
m_Y = m_Y - other.m_Y;
m_Z = m_Z - other.m_Z;
}
}
/// <summary>
/// Multiplies each component of the current FixPointVector3 by a given scalar value.
/// </summary>
/// <param name="scalar">The scalar value.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Multiply(FixPoint16 scalar)
{
unchecked
{
m_X = m_X * scalar;
m_Y = m_Y * scalar;
m_Z = m_Z * scalar;
}
}
/// <summary>
/// Multiplies the current FixPointVector3 by another FixPointVector3.
/// </summary>
/// <param name="other">The source vector.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Multiply(SFixPointVector3 other)
{
unchecked
{
m_X = m_X * other.m_X;
m_Y = m_Y * other.m_Y;
m_Z = m_Z * other.m_Z;
}
}
/// <summary>
/// Divides each component of the current FixPointVector3 by a given scalar value.
/// </summary>
/// <param name="divider">The scalar divider.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Divide(FixPoint16 divider)
{
#if DEBUG
if (divider.IsZero())
throw new("Error: divider is zero (FixPointVector3.Divide).");
#endif
unchecked
{
m_X = m_X / divider;
m_Y = m_Y / divider;
m_Z = m_Z / divider;
}
}
/// <summary>
/// Divides the current FixPointVector3 by another FixPointVector3.
/// </summary>
/// <param name="other">The vector divider.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Divide(SFixPointVector3 other)
{
unchecked
{
if (!other.m_X.IsZero() && !other.m_Y.IsZero() && !other.m_Z.IsZero())
{
m_X = m_X / other.m_X;
m_Y = m_Y / other.m_Y;
m_Z = m_Z / other.m_Z;
}
#if DEBUG
else
{
throw new("Error: divider vector contains zero (FixPointVector3.Divide).");
}
#endif
}
}
/// <summary>
/// Transforms a FixPointVector3.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
[Pure]
public static SFixPointVector3 Transform(SFixPointVector3 v, SFixPointQuaternionTransform t)
{
return Transform(v * t.m_Size, t.m_Orientation) + t.m_Position;
}
/// <summary>
/// Transforms a vector by the given Quaternion rotation value.
/// </summary>
/// <param name="value">The source vector to be rotated.</param>
/// <param name="rotation">The rotation to apply.</param>
/// <returns>The transformed vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Transform(SFixPointVector3 value, SFixPointQuaternion rotation)
{
var x2 = rotation.m_X + rotation.m_X;
var y2 = rotation.m_Y + rotation.m_Y;
var z2 = rotation.m_Z + rotation.m_Z;
var wx2 = rotation.m_W * x2;
var wy2 = rotation.m_W * y2;
var wz2 = rotation.m_W * z2;
var xx2 = rotation.m_X * x2;
var xy2 = rotation.m_X * y2;
var xz2 = rotation.m_X * z2;
var yy2 = rotation.m_Y * y2;
var yz2 = rotation.m_Y * z2;
var zz2 = rotation.m_Z * z2;
return new((value.m_X * (1 - yy2 - zz2)) + (value.m_Y * (xy2 - wz2)) + (value.m_Z * (xz2 + wy2)), (value.m_X * (xy2 + wz2)) + (value.m_Y * (1 - xx2 - zz2)) + (value.m_Z * (yz2 - wx2)),
(value.m_X * (xz2 - wy2)) + (value.m_Y * (yz2 + wx2)) + (value.m_Z * (1 - xx2 - yy2)));
}
#endregion
#region -- public static methods --------------------------------------
/// <summary>
/// Calculates the length of the given vector.
/// </summary>
/// <returns>The Length of the given vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Length(SFixPointVector3 value)
{
return value.Length();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CompareLength(SFixPointVector3 value, FixPoint16 fLength)
{
unchecked
{
return ((value.m_X.m_Value * (long)value.m_X.m_Value) + (value.m_Y.m_Value * (long)value.m_Y.m_Value) + (value.m_Z.m_Value * (long)value.m_Z.m_Value)).CompareTo(
fLength.m_Value * (long)fLength.m_Value);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int CompareLength(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
return ((value1.m_X.m_Value * (long)value1.m_X.m_Value) + (value1.m_Y.m_Value * (long)value1.m_Y.m_Value) + (value1.m_Z.m_Value * (long)value1.m_Z.m_Value)).CompareTo(
(value2.m_X.m_Value * (long)value2.m_X.m_Value) + (value2.m_Y.m_Value * (long)value2.m_Y.m_Value) + (value2.m_Z.m_Value * (long)value2.m_Z.m_Value));
}
}
/// <summary>
/// Computes the cross product of two vectors.
/// </summary>
/// <param name="vector1">The first vector.</param>
/// <param name="vector2">The second vector.</param>
/// <returns>The cross product.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Cross(SFixPointVector3 vector1, SFixPointVector3 vector2)
{
return new((vector1.m_Y * vector2.m_Z) - (vector1.m_Z * vector2.m_Y), (vector1.m_Z * vector2.m_X) - (vector1.m_X * vector2.m_Z),
(vector1.m_X * vector2.m_Y) - (vector1.m_Y * vector2.m_X));
}
/// <summary>
/// Calculates the distance between two vectors. (manhatten/taxi-cab metrix)
/// </summary>
/// <param name="left">The source vector</param>
/// <param name="right">The source vector</param>
/// <returns>Distance between the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 DistanceManhattan(SFixPointVector3 left, SFixPointVector3 right)
{
return FixPoint16.Abs(left.m_X - right.m_X) + FixPoint16.Abs(left.m_Y - right.m_Y) + FixPoint16.Abs(left.m_Z - right.m_Z);
}
/// <summary>
/// Calculates the distance between two vectors.
/// </summary>
/// <param name="left">The source vector</param>
/// <param name="right">The source vector</param>
/// <returns>Distance between the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Distance(SFixPointVector3 left, SFixPointVector3 right)
{
var dx = left.m_X - right.m_X;
var dy = left.m_Y - right.m_Y;
var dz = left.m_Z - right.m_Z;
return FixPoint16.Length(dx, dy, dz);
}
/// <summary>
/// Calculates the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16 Dot(SFixPointVector3 left, SFixPointVector3 right)
{
return (left.m_X * right.m_X) + (left.m_Y * right.m_Y) + (left.m_Z * right.m_Z);
}
/// <summary>
/// Calculates the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static FixPoint16Long DotLong(SFixPointVector3 left, SFixPointVector3 right)
{
return new() { m_Value = (left.m_X.m_Value * (long)right.m_X.m_Value) + (left.m_Y.m_Value * (long)right.m_Y.m_Value) + (left.m_Z.m_Value * (long)right.m_Z.m_Value) };
}
/// <summary>
/// Calculates the sign of the dot product of two vectors.
/// </summary>
/// <param name="left">The source vector.</param>
/// <param name="right">The source vector.</param>
/// <returns>The sign of the dot product of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int DotSign(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
return ((left.m_X.m_Value * (long)right.m_X.m_Value) + (left.m_Y.m_Value * (long)right.m_Y.m_Value) + (left.m_Z.m_Value * (long)right.m_Z.m_Value)).CompareTo(0);
}
}
/// <summary>
/// Creates a unit vector from the specified vector.
/// </summary>
/// <param name="value">The source vector.</param>
/// <returns>The created unit vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Normalize(SFixPointVector3 value)
{
return value.Normalized;
}
/// <summary>
/// Returns a vector that contains the lowest value from each matching pair of components.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>The minimized vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Min(SFixPointVector3 value1, SFixPointVector3 value2)
{
SFixPointVector3 result;
result.m_X = FixPoint16.Min(value1.m_X, value2.m_X);
result.m_Y = FixPoint16.Min(value1.m_Y, value2.m_Y);
result.m_Z = FixPoint16.Min(value1.m_Z, value2.m_Z);
return result;
}
/// <summary>
/// Returns a vector that contains the highest value from each matching pair of components.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>The maximized vector.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Max(SFixPointVector3 value1, SFixPointVector3 value2)
{
SFixPointVector3 result;
result.m_X = FixPoint16.Max(value1.m_X, value2.m_X);
result.m_Y = FixPoint16.Max(value1.m_Y, value2.m_Y);
result.m_Z = FixPoint16.Max(value1.m_Z, value2.m_Z);
return result;
}
/// <summary>
/// Returns a vector pointing in the opposite direction.
/// </summary>
/// <param name="value">The source vector.</param>
/// <returns>A new vector pointing in the opposite direction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Negate(SFixPointVector3 value)
{
unchecked
{
SFixPointVector3 result;
result.m_X = -value.m_X;
result.m_Y = -value.m_Y;
result.m_Z = -value.m_Z;
return result;
}
}
/// <summary>
/// Adds two vectors
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>A new vector representing the sum of the source vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Add(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + value2.m_X;
result.m_Y = value1.m_Y + value2.m_Y;
result.m_Z = value1.m_Z + value2.m_Z;
return result;
}
}
/// <summary>
/// Adds a given scalar value to each component of a given vector.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The scalar value to be added to the vector.</param>
/// <returns>A new FixPointVector3 representing the sum of the given vector and scalar.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Add(SFixPointVector3 value1, FixPoint16 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + value2;
result.m_Y = value1.m_Y + value2;
result.m_Z = value1.m_Z + value2;
return result;
}
}
/// <summary>
/// Subtracts a vector from another vector.
/// </summary>
/// <param name="value1">The vector to be subtracted from.</param>
/// <param name="value2">The vector to be subtracted.</param>
/// <returns>A new vector representing the result of the subtraction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Subtract(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X - value2.m_X;
result.m_Y = value1.m_Y - value2.m_Y;
result.m_Z = value1.m_Z - value2.m_Z;
return result;
}
}
/// <summary>
/// Subtracts a given scalar value from each component of a given vector.
/// </summary>
/// <param name="value1">The vector to be subtracted from.</param>
/// <param name="value2">The scalar value to subtracted.</param>
/// <returns>A new FixPointVector3 representing the result of the subtraction.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Subtract(SFixPointVector3 value1, FixPoint16 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X - value2;
result.m_Y = value1.m_Y - value2;
result.m_Z = value1.m_Z - value2;
return result;
}
}
/// <summary>
/// Multiplies the components of two vectors by each other.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <returns>A new vector representing the result of the mulitiplication.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Multiply(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X * value2.m_X;
result.m_Y = value1.m_Y * value2.m_Y;
result.m_Z = value1.m_Z * value2.m_Z;
return result;
}
}
/// <summary>
/// Multiplies a vector by a scalar value.
/// </summary>
/// <param name="value">The source vector.</param>
/// <param name="scalar">The scalar value.</param>
/// <returns>A new vector representing the result of the mulitiplication.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Multiply(SFixPointVector3 value, FixPoint16 scalar)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value.m_X * scalar;
result.m_Y = value.m_Y * scalar;
result.m_Z = value.m_Z * scalar;
return result;
}
}
/// <summary>
/// Divides the components of a vector by the components of another vector.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The divisor vector.</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(SFixPointVector3 value1, SFixPointVector3 value2)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X / value2.m_X;
result.m_Y = value1.m_Y / value2.m_Y;
result.m_Z = value1.m_Z / value2.m_Z;
return result;
}
}
/// <summary>
/// Projects a vector onto another vector.
/// </summary>
/// <param name="projected">The projected vector.</param>
/// <param name="projectionTarget">The vector the projected vector is being projected on.</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Project(SFixPointVector3 projected, SFixPointVector3 projectionTarget)
{
unchecked
{
SFixPointVector3 result;
var fpDot = Dot(projected, projectionTarget);
var fpProjectionTargetLength = projectionTarget.Length();
var fpScalar = fpDot / (fpProjectionTargetLength * fpProjectionTargetLength);
result = projectionTarget * fpScalar;
return result;
}
}
/// <summary>
/// Divides a vector by a scalar value.
/// </summary>
/// <param name="value">The source vector.</param>
/// <param name="divider">The divider</param>
/// <returns>A new vector representing the result of the division.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(SFixPointVector3 value, FixPoint16 divider)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value.m_X / divider;
result.m_Y = value.m_Y / divider;
result.m_Z = value.m_Z / divider;
return result;
}
}
/// <summary>
/// Creates a new FixPointVector3 with each component being the result of dividing a scalar value by the corresponding
/// component of a vector.
/// </summary>
/// <param name="value">The scalar value to be divided.</param>
/// <param name="divider">The divider vector</param>
/// <returns>
/// A new vector with each component being the result of dividing the scalar value by the corresponding component
/// of the vector.
/// </returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Divide(FixPoint16 value, SFixPointVector3 divider)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value / divider.m_X;
result.m_Y = value / divider.m_Y;
result.m_Z = value / divider.m_Z;
return result;
}
}
/// <summary>
/// Performs a linear interpolation between two vectors.
/// </summary>
/// <param name="value1">The source vector.</param>
/// <param name="value2">The source vector.</param>
/// <param name="amount">
/// The value between 0 and 1 indicating the weight of _value2. '0.0' will cause _value1 to be
/// returned; '1.0' will cause _value2 to be returned.
/// </param>
/// <returns>The linear interpolation of the two vectors.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 Lerp(SFixPointVector3 value1, SFixPointVector3 value2, FixPoint16 amount)
{
unchecked
{
SFixPointVector3 result;
result.m_X = value1.m_X + (amount * (value2.m_X - value1.m_X));
result.m_Y = value1.m_Y + (amount * (value2.m_Y - value1.m_Y));
result.m_Z = value1.m_Z + (amount * (value2.m_Z - value1.m_Z));
return result;
}
}
/// <summary>
/// Interpolates between two vectors using a cubic equation.
/// </summary>
/// <param name="value1">The source value.</param>
/// <param name="value2">The source value.</param>
/// <param name="amount">The weighting value.</param>
/// <returns>The interpolated value.</returns>
public static SFixPointVector3 SmoothStep(SFixPointVector3 value1, SFixPointVector3 value2, FixPoint16 amount)
{
var smootstep = FixPoint16.Min(0, FixPoint16.Max(1, amount));
smootstep = smootstep * smootstep * (3 - (2 * smootstep));
return Lerp(value1, value2, smootstep);
}
/// <summary>
/// Returns a FixPointVector3 containing the 2D Cartesian coordinates of a point specified in barycentric (areal)
/// coordinates relative to a 2D triangle.
/// </summary>
/// <param name="value1">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 1 of the triangle.</param>
/// <param name="value2">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 2 of the triangle.</param>
/// <param name="value3">A FixPointVector3 containing the 2D Cartesian coordinates of vertex 3 of the triangle.</param>
/// <param name="amount1">
/// Barycentric coordinate b2, which expresses the weighting factor toward vertex 2 (specified in
/// _value2).
/// </param>
/// <param name="amount2">
/// Barycentric coordinate b3, which expresses the weighting factor toward vertex 3 (specified in
/// _value3).
/// </param>
/// <returns>A new FixPointVector3 containing the 2D Cartesian coordinates of the specified point.</returns>
public static SFixPointVector3 Barycentric(SFixPointVector3 value1, SFixPointVector3 value2, SFixPointVector3 value3, FixPoint16 amount1, FixPoint16 amount2)
{
SFixPointVector3 result;
result.m_X = value1.m_X + (amount1 * (value2.m_X - value1.m_X)) + (amount2 * (value3.m_X - value1.m_X));
result.m_Y = value1.m_Y + (amount1 * (value2.m_Y - value1.m_Y)) + (amount2 * (value3.m_Y - value1.m_Y));
result.m_Z = value1.m_Z + (amount1 * (value2.m_Z - value1.m_Z)) + (amount2 * (value3.m_Z - value1.m_Z));
return result;
}
public static SFixPointVector3 Fract(SFixPointVector3 p)
{
return new(FixPoint16.Fract(p.m_X), FixPoint16.Fract(p.m_Y), FixPoint16.Fract(p.m_Z));
}
#endregion
#region -- operators --------------------------------------------------
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator ==(SFixPointVector3 left, SFixPointVector3 right)
{
return left.Equals(right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool operator !=(SFixPointVector3 left, SFixPointVector3 right)
{
return !left.Equals(right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 value)
{
return Negate(value);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator +(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
SFixPointVector3 result;
result.m_X = left.m_X + right.m_X;
result.m_Y = left.m_Y + right.m_Y;
result.m_Z = left.m_Z + right.m_Z;
return result;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator +(SFixPointVector3 left, FixPoint16 right)
{
return Add(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 left, SFixPointVector3 right)
{
unchecked
{
SFixPointVector3 result;
result.m_X = left.m_X - right.m_X;
result.m_Y = left.m_Y - right.m_Y;
result.m_Z = left.m_Z - right.m_Z;
return result;
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator -(SFixPointVector3 left, FixPoint16 right)
{
return Subtract(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(SFixPointVector3 left, SFixPointVector3 right)
{
return Multiply(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(SFixPointVector3 left, FixPoint16 right)
{
return new() {
m_X = left.m_X * right,
m_Y = left.m_Y * right,
m_Z = left.m_Z * right
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator *(FixPoint16 left, SFixPointVector3 right)
{
return Multiply(right, left);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(SFixPointVector3 left, SFixPointVector3 right)
{
return Divide(left, right);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(SFixPointVector3 left, FixPoint16 right)
{
return new() {
m_X = left.m_X / right,
m_Y = left.m_Y / right,
m_Z = left.m_Z / right
};
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SFixPointVector3 operator /(FixPoint16 left, SFixPointVector3 right)
{
return Divide(left, right);
}
#endregion
/// <summary>
/// The x-component of the vector.
/// </summary>
public FixPoint16 m_X;
/// <summary>
/// The y-component of the vector.
/// </summary>
public FixPoint16 m_Y;
/// <summary>
/// The z-component of the vector.
/// </summary>
public FixPoint16 m_Z;
}

View File

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

View File

@@ -1,119 +0,0 @@
using System;
namespace MagmaEngine.Math;
/// <summary>
/// Implements a XorShift* PRNG, with 64 bits of internal state.
/// See http://en.wikipedia.org/wiki/Xorshift
/// </summary>
public struct SIntRandom
{
public SIntRandom(ulong seed)
{
if (seed == 0)
throw new InvalidOperationException("Seed needs to be bigger than zero.");
m_Seed = seed;
}
public ulong Next()
{
m_Seed ^= m_Seed >> 12;
m_Seed ^= m_Seed << 25;
m_Seed ^= m_Seed >> 27;
return m_Seed * 2685821657736338717UL; // multiplier taken from wikipedia article on XorShift PRNGs
}
public ulong Next(ulong upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return Next() % upperLimit;
}
public FixPoint16 NextFixPoint16()
{
return new() { m_Value = (int)(Next() & 0xffffUL) };
}
public int RandomizedRound(FixPoint16 value)
{
int ret = value.ToIntFloor();
if (NextFixPoint16() < FixPoint16.Fract(value))
ret++;
return ret;
}
public uint Next(uint upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return (uint)(Next() % upperLimit);
}
public uint Next(uint lowerLimit, uint upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
if (lowerLimit == upperLimit)
{
return lowerLimit;
}
return lowerLimit + (uint)(Next() % (upperLimit - lowerLimit));
}
public int Next(int upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
return (int)(Next() % (uint)(upperLimit & 0x7fffffff));
}
public int Next(int lowerLimit, int upperLimit)
{
if (upperLimit == 0)
{
return 0;
}
if (lowerLimit == upperLimit)
{
return lowerLimit;
}
return lowerLimit + (int)(Next() % (uint)((upperLimit - lowerLimit) & 0x7fffffff));
}
public double NextDouble()
{
return Next(int.MaxValue) * (1.0 / int.MaxValue);
}
public float NextSingle()
{
return (float)NextDouble();
}
public override readonly string ToString()
{
return $"0x{m_Seed:X}";
}
public readonly ulong Seed => m_Seed;
private ulong m_Seed;
}

101
ROUGH-DESIGN.md Normal file
View File

@@ -0,0 +1,101 @@
I plan to participate in a gamejam, duration of one month. I want do do a side scrolling shooter in godot. Since the game jam topic is not yet known, I can't do a proper content design. But I can prepare a systems design. Please create a DESIGN.md page with my topics, and keep as many options open for when the final topic is known, so it can be customized. Here's what I want:
# Level
- multi-layered background parallax scrolling (without collision)
- interactive elements layer
- multi-layered foreground parallax scrolling (without collision)
- repeating
- might change as level progresses, need proper transition
- fixed camera path, varying speed
# Enemy Types
- consist of composed sprites, each with an own behavior
- A behavior can be one serial or many parallel sequences of events (traverse position/orientation path, fire a projectile, etc.)
- either spawned and move freely, or part of the interactive elements layer of the level
- may belong to a cluster
- may spawn a collectible when destroyed
# Enemy Clusters
- contain of several instances of several enemy types, spawned at different intervals
- destroying all enemies of a cluster awards points
# Colllectibles
- Add points
- Primary weapon (fill empty slot, otherwise replace current slot)
- Secondary weapon (replace)
- Clear screen
- Shield charge
- Special weapon ammunition
- Squadron mate of a specific type
# Hero
- Has a level, influencing primary weapon
- Collects points, point thresholds cause level up.
- Has fixed number of primary weapon slots, player can toggle current primary weapon
- Has initial number of shield charges (3). Getting hit reduces by one, leveling up increases by one.
- Dies when hit without shield charges
- Contains a max amount of squadron mates (4)
- Keeps collectibles at the end of a mission, begins next mission with kept collectibles
- Loses all collectibles on death
- Has 3 retry counts for rebirth after death. Game over when exhausted.
# Squadron mates
- All of them are of the same type, the type is determined by the last collected one.
- Maintain relative position to another squadron mate depending on type
- Collide/consume with enemy particles, taking no damage
## Types
- Hug: close together on hero's nose
- Orbit: circular motion around hero, mate id is phase offset
- Line-Formation: equidistant formation on top and on bottom of hero
- V-Formation: equidistant formation on back/top and on back/bottom of hero
- Follow: fly towards front of oldest enemy on screen
# Heros and Squadron mates
- Fire current primary weapon forwards
- Fire secondary weapon to another direction
# Primary weapons
- infinite ammo
- many/fast/weak or few/slow/strong ballistic, constant speed
- Flying only forward or seeking nearest enemy
- Laser, appear/disappear filling entire row
- Grenade cluster, fires fast, gets slower, big explosion on stop
- Possibility to collide and consume enemy projectiles
# Secondary weapons
- infinite ammo
- Always fire forwards
- Projectiles don't collide with enemy projectiles
- Fire additional projectiles depending on type
## Types
- Fire upwards if on top screen half, or downwards if on bottom screen half
- Fire upwards towards positive diagonal if on top screen half, or downwards towards negative diagonal if on bottom screen half
- Fire backwards
# Special weapons
- Can be chosen in shop
- Have initial ammo, may be replenished by collectible
## Special weapon types
- Bomb (gravity does its thing, explode once on impact). Lots of ammo.
- Crawler (gravity does its thing, crawls forwards following terrain contour, explodes once on enemy impact). Moderate ammo.
- Napalm (gravity does its thing, huge horizontal spread and 1s burn on impact). Moderate ammo.
- Black Hole (3s invulnerability period, pull enemies, destroyed upon impact with player). Few ammo.
# Game loop
- Logo, intro choreo, can be skipped
- Menu (start/controls)
- Mission loops
- Rebirth retry / Game over / Normal victory / Secret victory choreo
- Highscore
## Mission loop
- Shop for choosing one special weapon type for the next mission
- Queue mission specific music, play mission specific intro choreo
- Fade to actual gameplay
- Queue boss music, play boss specific intro choreo
- Mute music and play boss destruction vfx
- Queue victory music, play generic mission outro choreo and fade out

329
SLICE1.MD Normal file
View File

@@ -0,0 +1,329 @@
# Build the Godot Project Shell
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
This plan follows `PLANS.md` in the repository root. It is intentionally self-contained so a developer with only this repository and this file can implement Slice 1 without reading prior chat history.
## Purpose / Big Picture
After this slice, the repository will contain a bootable Godot 4 .NET project shell for the side-scrolling shooter. A developer will be able to build the C# project, start the Godot project, see a root scene load a normal placeholder scene, and boot directly into a smoke test scene through a debug boot mode. This gives later slices a stable place to add content, input, debug tools, and test harnesses without first repairing project setup.
The observable result is small but concrete: from `D:\Code\zfxaction26_1`, `dotnet build SideScrollerGame.sln` succeeds, `.\godot --headless --path godot --build-solutions --quit` succeeds, and `.\godot --headless --path godot -- --debug-boot=smoke` starts the root scene, loads the smoke scene, prints a short confirmation line, and exits with code 0.
## Progress
- [x] (2026-04-21 16:36Z) Read repository rules, Windows rules, `PLANS.md`, `CODE.md`, and current Godot project files.
- [x] (2026-04-21 16:36Z) Verified that the Godot project currently lives in `godot/project.godot`, not at the repository root.
- [x] (2026-04-21 16:36Z) Verified that `SideScrollerGame.sln` and `godot/SideScrollerGame.Godot.csproj` currently build with `dotnet build SideScrollerGame.sln` after the user's fixes.
- [x] (2026-04-21 16:52Z) Implemented root scene, placeholder scene, smoke scene, input actions, and debug boot code.
- [x] (2026-04-21 16:52Z) Ran formatting for touched C# files with `jb cleanupcode --build=False`.
- [x] (2026-04-21 16:52Z) Validated with .NET build, Godot solution build, and headless smoke boot.
- [x] (2026-04-21 16:52Z) Commit the completed slice.
## Surprises & Discoveries
- Observation: The current Godot project is under the `godot/` subdirectory. The repository-root wrapper is still used from `D:\Code\zfxaction26_1`, but commands must pass `--path godot`.
Evidence: `rg --files godot` lists `godot/project.godot` and `godot/SideScrollerGame.Godot.csproj`.
- Observation: A prior `.\godot --headless --path godot --build-solutions` command was observed to keep running without `--quit`.
Evidence: the process command line was `--headless --path godot --build-solutions`, and it had to be stopped. This plan uses `--build-solutions --quit` for validation.
- Observation: Running `dotnet build SideScrollerGame.sln` and `dotnet build godot\SideScrollerGame.Godot.csproj` at the same time can race on the same Godot temp assembly.
Evidence: the parallel build attempt failed with `CS2012: Cannot open ... SideScrollerGame.Godot.dll for writing`. A serial `dotnet build SideScrollerGame.sln` immediately afterward succeeded.
- Observation: `godot/project.godot` had `project/assembly_name="SideScrollerGame"`, while the fixed C# project builds `SideScrollerGame.Godot.dll`.
Evidence: the first smoke boot did not instantiate C# scripts, and `.\godot --headless --path godot --quit` reported that `GameRoot.cs` and `DebugOverlay.cs` classes could not be found. Updating the Godot assembly name to `SideScrollerGame.Godot` fixed script loading.
- Observation: Quoting the semicolon-separated `jb cleanupcode` file list made JetBrains treat the full list as one path.
Evidence: `jb cleanupcode --build=False "file1;file2;..."` exited with "No items were found to cleanup." Running `jb cleanupcode --build=False file1 file2 ...` formatted all touched C# files.
## Decision Log
- Decision: Keep `godot/project.godot` as the canonical Godot project entrypoint for this slice.
Rationale: The current repository already has the Godot project under `godot/`, the user explicitly corrected that it is not missing, and moving it would create unnecessary churn before gameplay exists.
Date/Author: 2026-04-21 / Codex.
- Decision: Implement Slice 1 as a minimal boot shell with placeholder scenes, not gameplay systems.
Rationale: `CODE.md` defines Slice 1 as the project shell. Later slices depend on stable project loading, input actions, debug boot mode, and a reliable smoke test.
Date/Author: 2026-04-21 / Codex.
- Decision: Use C# scripts for root boot, debug settings, overlay, and smoke exit behavior.
Rationale: This is a Godot .NET project, and later slices need reusable C# debug services and typed state. Keeping this in C# avoids replacing GDScript glue later.
Date/Author: 2026-04-21 / Codex.
- Decision: Parse debug boot settings from Godot user arguments such as `--debug-boot=smoke` and `--seed=12345`, with project settings as fallback.
Rationale: Command-line boot modes make sandbox and smoke testing fast without editor interaction. Project setting fallback keeps editor boot predictable.
Date/Author: 2026-04-21 / Codex.
- Decision: Match `godot/project.godot`'s .NET assembly name to the existing C# project output, `SideScrollerGame.Godot`.
Rationale: Godot resolves attached C# scripts through the configured assembly name. The project already builds `SideScrollerGame.Godot.dll`, and changing the Godot setting avoids touching the user's fixed `.csproj`.
Date/Author: 2026-04-21 / Codex.
## Outcomes & Retrospective
Implemented a bootable Godot project shell under `godot/`. Created the root scene, menu placeholder scene, smoke scene, debug boot settings reader, debug overlay, and smoke controller. Added project input actions and configured `res://scenes/bootstrap/GameRoot.tscn` as the main scene.
Validation completed:
dotnet build SideScrollerGame.sln
Build succeeded.
0 Warning(s)
0 Error(s)
.\godot --headless --path godot --build-solutions --quit
Exited successfully.
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Debug boot: Smoke
Seed: 12345
Smoke scene loaded
Remaining risk: editor visual layout was not manually inspected in a window during this slice. The headless boot path and script loading are validated.
## Context and Orientation
The repository root is `D:\Code\zfxaction26_1`. The Godot project root is `D:\Code\zfxaction26_1\godot`. The wrapper `D:\Code\zfxaction26_1\godot.cmd` launches the local Godot 4.5.1 .NET executable, so commands are run from the repository root as `.\godot --path godot ...`.
Current important files are:
- `godot/project.godot`: the Godot project configuration. It currently has project name, C# feature flags, icon, and .NET assembly name, but no main scene and no input actions.
- `godot/SideScrollerGame.Godot.csproj`: the C# project for Godot scripts. It currently targets `net8.0` and uses `Godot.NET.Sdk/4.5.1`.
- `SideScrollerGame.sln`: the Visual Studio solution. It currently contains the Godot C# project and builds successfully after the user's fixes.
- `CODE.md`: the broader implementation plan. Its Slice 1 section asks for a project shell, root scene, input actions, smoke scene, debug boot mode, deterministic seed setting, and visible build/version label.
- `DESIGN.md`: the design document. It has user modifications and must not be touched by this slice.
There are currently no tracked scenes or gameplay scripts under `godot/scenes/` or `godot/scripts/`. This slice creates those directories and the minimal files needed to boot.
Definitions used in this plan:
- A root scene is the first Godot scene loaded by the project. It owns scene switching and debug boot selection.
- A placeholder scene is a simple visible scene used before real menu or gameplay exists.
- A smoke scene is a tiny scene used to prove the project can start. In headless mode it exits automatically with code 0 so it can be used by scripts and CI.
- Headless mode means Godot runs without opening a window. It is used for build and smoke validation.
- Debug boot mode means a developer can start the project directly in a specific scene by passing a command-line argument instead of clicking through menus.
## Plan of Work
First, create the scene and script folders under `godot/` so the project follows the planned layout without touching unrelated files. Add `godot/scripts/debug/DebugBootMode.cs`, an enum with at least `Menu` and `Smoke`. Add `godot/scripts/debug/DebugSettings.cs`, a small settings reader that parses `OS.GetCmdlineUserArgs()` for `--debug-boot=<value>` and `--seed=<integer>`, then falls back to Godot project settings `application/run/debug_boot_mode` and `application/run/debug_seed`.
Next, add `godot/scripts/bootstrap/GameRoot.cs`. This script should extend `Godot.Node`, read `DebugSettings` in `_Ready`, instantiate either the menu placeholder scene or smoke scene, and add it as a child. It should also create or update a debug overlay label so the screen shows the active boot mode, seed, current scene id, and whether debug mode is active. The root script should expose scene paths or `PackedScene` exports so a designer can change them in the Godot editor later.
Then add `godot/scripts/debug/DebugOverlay.cs`. It should extend `CanvasLayer` or `Control`, contain a `Label`, and expose a method like `SetStatus(DebugSettings settings, string loadedSceneId)`. Keep it simple: it only needs to show text in the top-left corner. Later slices will extend the overlay with mission time, enemies, projectiles, and hero state.
Then add `godot/scripts/menu/MenuPlaceholder.cs` and `godot/scripts/debug/SmokeSceneController.cs`. The menu placeholder should show a label such as `SideScrollerGame - Menu Placeholder` and a short debug instruction such as `Run with -- --debug-boot=smoke for smoke scene`. The smoke controller should show a simple placeholder hero shape or label. If the display server is headless, it should print `Smoke scene loaded` and quit successfully after one idle frame. In a normal window, it should stay visible.
Next, create the scene files:
- `godot/scenes/bootstrap/GameRoot.tscn`
- `godot/scenes/menu/MenuPlaceholder.tscn`
- `godot/scenes/debug/SmokeScene.tscn`
The root scene should be a `Node` with the `GameRoot.cs` script attached and a child `DebugOverlay`. The menu placeholder should be a `Control` or `Node2D` with a label and the `MenuPlaceholder.cs` script attached. The smoke scene should be a `Node2D` with a label or simple `Polygon2D` hero placeholder and the `SmokeSceneController.cs` script attached.
Finally, update `godot/project.godot`. Add `config/main_scene="res://scenes/bootstrap/GameRoot.tscn"` under `[application]`. Add input map entries for `move_up`, `move_down`, `move_left`, `move_right`, `fire_primary`, `fire_secondary`, `fire_special`, `pause_game`, `debug_overlay`, and `quick_restart`. Add project settings for default debug boot mode and seed. Keep the existing project name, features, icon, and .NET assembly name.
Do not edit `DESIGN.md`. Do not revert the user's current `SideScrollerGame.sln` or `godot/SideScrollerGame.Godot.csproj` changes. If those files must be touched during implementation, read the current content first and make only the minimal required edits.
## Concrete Steps
Run all commands from `D:\Code\zfxaction26_1`.
1. Confirm the current state before editing:
git status --short
rg --files godot
dotnet build SideScrollerGame.sln
Expected result: `git status --short` may show user changes to `DESIGN.md`, `SideScrollerGame.sln`, and `godot/SideScrollerGame.Godot.csproj`. `rg --files godot` should show the current Godot project files. `dotnet build SideScrollerGame.sln` should end with `Build succeeded`.
2. Create directories:
godot/scenes/bootstrap
godot/scenes/menu
godot/scenes/debug
godot/scripts/bootstrap
godot/scripts/menu
godot/scripts/debug
3. Add the C# scripts listed in the Plan of Work. Use 4 spaces for indentation. Use nullable reference types where practical. Keep one primary public type per file. Follow the repository type member order from `AGENTS.md`: nested types, constructors, disposable implementation, methods, properties, static members, events, fields.
4. Add the three scene files and attach the scripts. Prefer simple Godot built-in nodes and the existing `godot/icon.svg` for any placeholder image. No imported art is required for this slice.
5. Update `godot/project.godot` to set the main scene, input actions, and default debug settings. Use Godot's existing text format and preserve existing settings.
6. Format touched C# files:
jb cleanupcode --build=False godot\scripts\bootstrap\GameRoot.cs;godot\scripts\debug\DebugBootMode.cs;godot\scripts\debug\DebugSettings.cs;godot\scripts\debug\DebugOverlay.cs;godot\scripts\debug\SmokeSceneController.cs;godot\scripts\menu\MenuPlaceholder.cs
If `jb cleanupcode` is unavailable, record the exact error in `Surprises & Discoveries`, keep formatting manually consistent, and continue validation.
7. Validate the project:
dotnet build SideScrollerGame.sln
.\godot --headless --path godot --build-solutions --quit
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Expected result: the .NET build succeeds, the Godot build exits, and the smoke boot prints a line containing `Smoke scene loaded` and exits with code 0.
8. Check the diff:
git status --short
git diff -- godot/project.godot godot/scenes godot/scripts SLICE1.MD
Expected result: the slice diff only includes the intended project shell files. User changes in `DESIGN.md`, `SideScrollerGame.sln`, or `godot/SideScrollerGame.Godot.csproj` must not be reverted.
9. Commit this slice after validation:
git add godot/project.godot godot/scenes godot/scripts SLICE1.MD
git commit -m "Add Godot project shell"
## Validation and Acceptance
This slice is accepted when the following observable behaviors are true.
Building from the repository root succeeds:
dotnet build SideScrollerGame.sln
The expected final lines include:
Build succeeded.
0 Warning(s)
0 Error(s)
Godot can build the C# solution and exit:
.\godot --headless --path godot --build-solutions --quit
The command should exit without a stuck Godot process. If it prints Godot warnings that do not fail the process, record them in this plan.
The project can boot through its main scene into the smoke scene:
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
The expected output includes:
Smoke scene loaded
Debug boot: Smoke
Seed: 12345
Opening the editor should show the project and root scene:
.\godot --editor --path godot
In the editor, `res://scenes/bootstrap/GameRoot.tscn` should be the configured main scene. Running it without arguments should show the menu placeholder and debug/version label. Running it with `-- --debug-boot=smoke` should show the smoke placeholder in a window or exit automatically in headless mode.
There is no automated unit test required for this slice because no pure gameplay rules exist yet. The smoke scene is the automated project-start test.
## Idempotence and Recovery
The implementation is additive and safe to repeat. Creating directories that already exist should do nothing. Re-running the validation commands should produce the same result.
If scene files are malformed, open the project with `.\godot --editor --path godot`, let Godot report the scene load error, and repair the smallest affected scene file. Do not delete unrelated files. If Godot generates `.uid` files for new scenes or scripts, keep them if Godot requires them; otherwise do not hand-edit generated IDs.
If `.\godot --headless --path godot --build-solutions --quit` hangs, stop only the stuck Godot process, record the command and process details in `Surprises & Discoveries`, and validate with `dotnet build SideScrollerGame.sln` plus the smoke boot command. Do not use broad process-kill commands that could stop an editor session the user is actively using.
If user changes appear in files outside this slice, leave them untouched. If a user change conflicts with `godot/project.godot`, read the file and merge only the needed main scene, input map, and debug setting entries.
## Artifacts and Notes
Current verification before implementation:
dotnet build SideScrollerGame.sln
Determining projects to restore...
All projects are up-to-date for restore.
SideScrollerGame.Godot -> D:\Code\zfxaction26_1\godot\.godot\mono\temp\bin\Debug\SideScrollerGame.Godot.dll
Build succeeded.
0 Warning(s)
0 Error(s)
Current Godot project files before implementation:
godot\SideScrollerGame.Godot.csproj
godot\project.godot
godot\icon.svg.import
godot\icon.svg
Current dirty files before implementation:
M DESIGN.md
M SideScrollerGame.sln
M godot/SideScrollerGame.Godot.csproj
Those modified files are user work. This slice must not revert them.
## Interfaces and Dependencies
The project uses Godot 4.5.1 .NET through `Godot.NET.Sdk/4.5.1` and targets `net8.0` for desktop builds. Scripts should use the `Godot` C# namespace and partial Godot classes where required by the engine.
At the end of this slice, these C# types should exist:
In `godot/scripts/debug/DebugBootMode.cs`:
namespace SideScrollerGame.Debug;
public enum DebugBootMode
{
Menu,
Smoke
}
In `godot/scripts/debug/DebugSettings.cs`:
namespace SideScrollerGame.Debug;
public sealed class DebugSettings
{
public DebugBootMode BootMode { get; }
public int Seed { get; }
public static DebugSettings Load();
}
`DebugSettings.Load()` must read user command-line arguments first, then fall back to project settings. Unknown boot modes should fall back to `Menu` and print a Godot warning.
In `godot/scripts/bootstrap/GameRoot.cs`:
namespace SideScrollerGame.Bootstrap;
public partial class GameRoot : Node
{
public override void _Ready();
public void LoadBootScene(DebugBootMode bootMode);
}
`GameRoot` must instantiate `res://scenes/menu/MenuPlaceholder.tscn` for `Menu` and `res://scenes/debug/SmokeScene.tscn` for `Smoke`.
In `godot/scripts/debug/DebugOverlay.cs`:
namespace SideScrollerGame.Debug;
public partial class DebugOverlay : CanvasLayer
{
public void SetStatus(DebugSettings settings, string loadedSceneId);
}
The overlay should be visible by default in non-release builds. It should be small, top-left, and not block input.
In `godot/scripts/debug/SmokeSceneController.cs`:
namespace SideScrollerGame.Debug;
public partial class SmokeSceneController : Node2D
{
public override void _Ready();
}
In headless mode, the smoke controller should print confirmation and call `GetTree().Quit(0)` after at least one idle frame.
In `godot/scripts/menu/MenuPlaceholder.cs`:
namespace SideScrollerGame.Menu;
public partial class MenuPlaceholder : Control
{
public override void _Ready();
}
The menu placeholder may only set label text in this slice. Real menu behavior belongs to a later slice.
Revision note 2026-04-21: Created this ExecPlan from current repository state. The plan keeps `godot/project.godot` as the project entrypoint, incorporates the user's fixed solution and C# project state, and scopes Slice 1 to a bootable Godot shell with debug boot and smoke validation.

444
SLICE2.MD Normal file
View File

@@ -0,0 +1,444 @@
# Add Core Data Definitions
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
This plan follows `PLANS.md` in the repository root. It is intentionally self-contained so a developer with only this repository and this file can implement Slice 2 without reading prior chat history.
## Purpose / Big Picture
After this slice, the project will have a small content definition layer for the side-scrolling shooter. A developer will be able to describe a mission, difficulty, camera path, parallax layers, enemies, behavior tracks, clusters, collectibles, weapons, special weapons, and squadron mate types without hardcoding those choices into gameplay scenes.
The observable result is concrete: from `D:\Code\zfxaction26_1`, `dotnet test SideScrollerGame.sln` validates the sample content and intentionally broken content, `dotnet build SideScrollerGame.sln` succeeds, and `.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only` loads the project, validates the sample registry, prints a short list of loaded definition ids, prints `Content validation succeeded`, and exits with code 0.
This slice does not implement gameplay movement, enemy spawning, weapons, or the mission runner. It creates the contracts and validation needed so those later systems can load data confidently and fail fast when sample content is incomplete or contradictory.
## Progress
- [x] (2026-04-21 17:04Z) Read repository rules, Windows rules, `PLANS.md`, `DESIGN.md`, `CODE.md`, `SLICE1.MD`, current Godot scripts, and `godot/project.godot`.
- [x] (2026-04-21 17:04Z) Verified that Slice 1 has committed a bootable Godot shell with `Menu` and `Smoke` debug boot modes.
- [x] (2026-04-21 17:04Z) Created this Slice 2 ExecPlan.
- [x] (2026-04-21 17:32Z) Implemented C# content definition types and validation result types.
- [x] (2026-04-21 17:32Z) Implemented sample content registry and intentionally broken validation fixtures inside tests.
- [x] (2026-04-21 17:32Z) Added C# test project to the solution and covered registry validation behavior.
- [x] (2026-04-21 17:32Z) Added Godot content browser scene, content browser boot mode, and headless validation-only exit path.
- [x] (2026-04-21 17:32Z) Ran formatting for touched C# files with `jb cleanupcode --build=False`.
- [x] (2026-04-21 17:32Z) Validated with .NET tests, .NET build, Godot solution build, and headless content browser boot.
- [x] (2026-04-21 17:32Z) Commit the completed slice.
## Surprises & Discoveries
- Observation: The active Godot project is under `godot/`, not at the repository root.
Evidence: `godot/project.godot` is the configured project file, the wrapper command is run from the repository root as `.\godot --path godot ...`, and Slice 1 validation uses that layout.
- Observation: The current boot shell has only `Menu` and `Smoke` debug boot modes.
Evidence: `godot/scripts/debug/DebugBootMode.cs` contains only `Menu` and `Smoke`, and `godot/scripts/bootstrap/GameRoot.cs` switches every non-smoke mode to the menu placeholder.
- Observation: There are no existing gameplay content definitions or tests in the repository.
Evidence: `rg --files` shows only bootstrap, menu, debug smoke scripts and scenes under `godot/`; there is no `tests/` directory.
- Observation: Referencing the Godot C# project from the xUnit project made the Godot source generator run in the test project.
Evidence: the first `dotnet test SideScrollerGame.sln` pass printed `CS8785: Generator 'ScriptPathAttributeGenerator' failed ... Property 'GodotProjectDir' is null or empty`. Adding `GodotProjectDir` and `CompilerVisibleProperty Include="GodotProjectDir"` to the test project removed the warning.
- Observation: `.\godot --headless --path godot --build-solutions --quit` generated `.cs.uid` files for every new C# script, including plain content definitions.
Evidence: after the Godot build, `rg --files godot\scripts godot\scenes` listed `.uid` files beside all new content and validation C# files. These are kept because Godot generated them for the project scan.
## Decision Log
- Decision: Implement Slice 2 definitions as plain C# records and small enums inside the existing Godot C# project first, not as Godot `Resource` assets.
Rationale: Plain C# records are fast to write, easy to unit test without launching Godot, easy to refactor during the jam, and can later be backed by Godot resources or JSON if editor authoring becomes more important than code-side iteration.
Date/Author: 2026-04-21 / Codex.
- Decision: Add a separate .NET test project for content validation.
Rationale: Definition validation is pure rule logic. Running it through `dotnet test` is faster and more reliable than launching the Godot editor for every validation case.
Date/Author: 2026-04-21 / Codex.
- Decision: Add a Godot content browser debug boot mode even though the unit tests cover validation.
Rationale: Later slices need a fast in-engine way to confirm that designers can see loaded content ids and validation errors before entering gameplay. This also proves the definitions can be consumed from Godot scenes, not only from tests.
Date/Author: 2026-04-21 / Codex.
- Decision: Keep sample definitions theme-neutral.
Rationale: The jam topic is unknown. Stable ids such as `mission.test`, `enemy.serial`, and `weapon.primary.basic` prove system wiring without committing to a visual or story theme.
Date/Author: 2026-04-21 / Codex.
- Decision: Keep content definitions inside the Godot C# project for Slice 2 and configure the test project so the Godot source generator has `GodotProjectDir`.
Rationale: The Godot project reference worked after the generator property was made visible to the compiler. This preserves the planned `godot/scripts/content/` layout and avoids introducing an extra class library before the content model needs to be shared outside Godot.
Date/Author: 2026-04-21 / Codex.
## Outcomes & Retrospective
Implemented the core content definition layer under `godot/scripts/content/`, including definitions, registry lookup, validation messages, validation rules, and theme-neutral sample content. Added `tests/SideScrollerGame.Content.Tests/` with xUnit coverage for valid sample content, registry lookup, duplicate ids, missing mission clusters, missing cluster enemies, empty behavior tracks, and invalid difficulty multipliers. Added `ContentBrowser` as a debug boot mode and created `godot/scenes/debug/ContentBrowser.tscn` for in-engine content inspection and headless validation.
Validation completed:
dotnet test SideScrollerGame.sln
Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8
dotnet build SideScrollerGame.sln
Build succeeded.
0 Warning(s)
0 Error(s)
.\godot --headless --path godot --build-solutions --quit
Exited successfully.
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Debug boot: ContentBrowser
Seed: 1
Loaded content definitions:
behavior.enemy.parallel
behavior.enemy.serial
camera.test.path
cluster.opening
collectible.points.small
collectible.squadron.orbit
difficulty.easy
difficulty.hard
difficulty.normal
enemy.parallel
enemy.serial
layer.background.stars
layer.foreground.clouds
mission.test
squadron.orbit
weapon.primary.basic
weapon.secondary.vertical
weapon.special.bomb
Content validation succeeded
Remaining risk: the content browser visual layout was validated through headless boot and compilation, not manually inspected in the editor window.
## Context and Orientation
The repository root is `D:\Code\zfxaction26_1`. The Godot project root is `D:\Code\zfxaction26_1\godot`. The repo-local wrapper `D:\Code\zfxaction26_1\godot.cmd` launches Godot 4.5.1 .NET, so Godot commands are run from the repository root with `.\godot --path godot ...`.
Current important files are:
- `godot/project.godot`: Godot project configuration. It currently sets `run/main_scene="res://scenes/bootstrap/GameRoot.tscn"` and `project/assembly_name="SideScrollerGame.Godot"`.
- `godot/SideScrollerGame.Godot.csproj`: the Godot C# project. It currently targets `net8.0`, uses `Godot.NET.Sdk/4.5.1`, and builds `SideScrollerGame.Godot.dll`.
- `SideScrollerGame.sln`: the Visual Studio solution. It currently contains the Godot C# project.
- `godot/scripts/bootstrap/GameRoot.cs`: the root scene script. It reads `DebugSettings`, seeds Godot randomness, prints the boot mode and seed, then loads either the menu placeholder or smoke scene.
- `godot/scripts/debug/DebugBootMode.cs`: the enum for debug boot modes. It currently contains `Menu` and `Smoke`.
- `godot/scripts/debug/DebugSettings.cs`: command-line and project-setting reader for `--debug-boot=<value>` and `--seed=<integer>`.
- `godot/scripts/debug/DebugOverlay.cs`: tiny top-left debug label used by the root scene.
- `godot/scenes/bootstrap/GameRoot.tscn`: main scene with `GameRoot.cs`, `MenuScene`, `SmokeScene`, and a `DebugOverlay` child.
- `CODE.md`: the broader implementation plan. Its Slice 2 section asks for definitions, sample data, a content registry, validation methods, and a debug content browser.
- `DESIGN.md`: the systems design source. It describes the data-driven content objects and rules that Slice 2 must represent. Do not rewrite this file in Slice 2 unless the user explicitly asks for design changes.
- `SLICE1.MD`: the previous ExecPlan. It documents the boot shell and validation commands used by this repo.
Definitions used in this plan:
- A definition is immutable content data that describes what something is, such as a weapon id, enemy health, cluster spawn time, or difficulty multiplier. A definition should not own live scene nodes or mutable gameplay state.
- A stable id is a string that other definitions can reference. Stable ids should use lowercase dotted names, such as `mission.test` or `weapon.primary.basic`, so sample content is easy to search and compare.
- A content registry is a central object that exposes all known definitions by stable id. Later gameplay code will ask the registry for definitions instead of constructing content directly.
- Validation means checking content for broken references and invalid numbers before gameplay starts. For example, a mission that references `cluster.missing` should produce a clear validation error naming that missing id.
- A content browser is a debug scene that lists loaded definition ids and validation messages. It is not the final game UI; it is a fast testing tool.
- Headless mode means Godot runs without opening a window. This plan uses headless mode to prove the content browser and validation can run from the command line.
## Plan of Work
First, create a pure content model under `godot/scripts/content/`. Use subfolders so the file locations communicate intent: `godot/scripts/content/definitions/` for definition records and enums, `godot/scripts/content/validation/` for validation results, and `godot/scripts/content/samples/` for hardcoded sample content. Keep one primary public type per file, with class names matching filenames.
Add definition records for the Slice 2 objects named in `CODE.md` and `DESIGN.md`: `MissionDefinition`, `DifficultyDefinition`, `CameraPathDefinition`, `LevelLayerDefinition`, `EnemyTypeDefinition`, `EnemyBehaviorDefinition`, `EnemyClusterDefinition`, `CollectibleDefinition`, `WeaponDefinition`, `SpecialWeaponDefinition`, and `SquadronMateTypeDefinition`. Also add small supporting records and enums where they keep the main definitions readable, such as `DefinitionId`, `SpawnScheduleEntryDefinition`, `CameraPathPointDefinition`, `LayerKind`, `BehaviorTrackMode`, `BehaviorEventKind`, `CollectibleKind`, `WeaponKind`, `SpecialWeaponKind`, `SquadronMateFormationKind`, `ClusterEscapeRule`, and `DifficultyModifierSet`.
Keep the data minimal but useful. Do not model every future parameter in detail. Each definition should contain the stable id, display name or debug name, and the fields needed to validate cross-references and starter balance. For example, `EnemyClusterDefinition` should include an id, a reward score, an escape rule, and spawn entries that reference enemy type ids. `MissionDefinition` should include an id, a default difficulty id, camera path id, background and foreground layer ids, cluster ids, collectible ids, special weapon ids, and timeline marker names. `WeaponDefinition` should include kind, damage, fire cadence seconds, projectile speed, projectile count, and whether it consumes enemy projectiles.
Next, implement validation in `godot/scripts/content/validation/`. Add `ContentValidationSeverity` with `Info`, `Warning`, and `Error`. Add `ContentValidationMessage` with severity, code, message, and optional definition id. Add `ContentValidationResult` with an `IReadOnlyList<ContentValidationMessage>`, a `HasErrors` property, and helper constructors. Add `ContentValidator` with a method `Validate(ContentRegistry registry)` that checks every sample definition collection. Validation should report missing ids, duplicate ids, empty required lists, invalid timings, invalid multipliers, invalid health and damage values, invalid weapon slots, broken mission references, cluster spawn entries that reference missing enemies, behavior tracks with no events, event durations below zero, and difficulties with non-positive multipliers.
Then add `ContentRegistry` under `godot/scripts/content/`. It should own read-only dictionaries keyed by stable id. It should expose typed lookup methods such as `TryGetMission`, `TryGetDifficulty`, and `TryGetEnemyType`, plus an `AllDefinitionIds()` method that returns stable ids grouped or sorted for display. Add `SampleContent.CreateRegistry()` in `godot/scripts/content/samples/` to create one test mission and enough referenced content for validation to pass.
The sample content must prove the real vertical slice shape without implementing gameplay. Include at least these ids:
- Difficulty ids: `difficulty.easy`, `difficulty.normal`, `difficulty.hard`.
- Camera path id: `camera.test.path`.
- Layer ids: `layer.background.stars`, `layer.foreground.clouds`.
- Enemy behavior ids: `behavior.enemy.serial`, `behavior.enemy.parallel`.
- Enemy type ids: `enemy.serial`, `enemy.parallel`.
- Cluster id: `cluster.opening`.
- Collectible ids: `collectible.points.small`, `collectible.squadron.orbit`.
- Primary weapon id: `weapon.primary.basic`.
- Secondary weapon id: `weapon.secondary.vertical`.
- Special weapon id: `weapon.special.bomb`.
- Squadron mate type id: `squadron.orbit`.
- Mission id: `mission.test`.
Add an intentionally broken sample builder, such as `BrokenSampleContent.CreateRegistryWithMissingEnemyReference()`, that is used only by tests. This avoids weakening the main sample content while proving validation catches bad data.
Next, add a test project under `tests/SideScrollerGame.Content.Tests/`. Use xUnit or another standard .NET test framework already available through NuGet. If no test framework is already present, choose xUnit because it works cleanly with `dotnet test` and does not require Godot to launch. Add the test project to `SideScrollerGame.sln`. The test project should reference `godot/SideScrollerGame.Godot.csproj` or, if that proves awkward because of Godot SDK behavior, create a small plain class library `src/SideScrollerGame.Content/` and move the pure content model there. Prefer the smallest working approach, but record any project-structure decision in this plan.
Cover these behaviors with tests:
- `SampleContent.CreateRegistry()` validates with no errors.
- Duplicate ids produce an error naming the duplicated id.
- A mission with a missing cluster id produces an error naming the mission and missing cluster.
- A cluster with a missing enemy type id produces an error naming the cluster and missing enemy.
- A behavior track with no events produces an error naming the behavior.
- A difficulty with a non-positive multiplier produces an error naming the difficulty and field.
- The registry can look up `mission.test`, `enemy.serial`, `weapon.primary.basic`, and `difficulty.normal`.
Then add a Godot content browser. Create `godot/scenes/debug/ContentBrowser.tscn` and `godot/scripts/debug/ContentBrowserController.cs`. The scene should be a `Control` with a title label and a multiline text label. The controller should create the sample registry, validate it, list definitions by id, and show validation messages. If Godot is headless and the command line contains `--content-validate-only`, it should print all loaded definition ids, print either `Content validation succeeded` or `Content validation failed`, then quit with exit code 0 for success and 1 for validation errors.
Update `godot/scripts/debug/DebugBootMode.cs` to add `ContentBrowser`. Update `godot/scripts/bootstrap/GameRoot.cs` to export a `PackedScene? ContentBrowserScene` and load it when the boot mode is `ContentBrowser`. Update `godot/scenes/bootstrap/GameRoot.tscn` to reference `res://scenes/debug/ContentBrowser.tscn`. Keep existing `Menu` and `Smoke` behavior unchanged.
Finally, update this ExecPlan as work proceeds. Do not edit `DESIGN.md` as part of this slice. If `SideScrollerGame.sln` or `godot/SideScrollerGame.Godot.csproj` have unrelated user edits, preserve them and stage only the minimal solution or project changes required for tests.
## Concrete Steps
Run all commands from `D:\Code\zfxaction26_1`.
1. Confirm the current state before editing:
git status --short
rg --files godot
dotnet build SideScrollerGame.sln
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Expected result: `git status --short` should show only changes the user intentionally left in the worktree, if any. The .NET build should end with `Build succeeded`. The smoke boot should print `Debug boot: Smoke`, `Seed: 12345`, and `Smoke scene loaded`.
2. Create content folders:
godot/scripts/content
godot/scripts/content/definitions
godot/scripts/content/validation
godot/scripts/content/samples
Creating directories that already exist is safe.
3. Add the definition records and enums described in the Plan of Work. Keep the public namespace `SideScrollerGame.Content.Definitions` for definition types. Use `SideScrollerGame.Content.Validation` for validation types. Use `SideScrollerGame.Content.Samples` for sample builders.
4. Add `godot/scripts/content/ContentRegistry.cs`. It should accept all definition collections in its constructor, build dictionaries keyed by id, and expose read-only collections for validators and the debug browser. It should not depend on Godot scene nodes.
5. Add `godot/scripts/content/validation/ContentValidator.cs`. It should validate a whole registry and return `ContentValidationResult`. Do not throw exceptions for normal content mistakes; return validation messages so the browser and tests can display them.
6. Add `godot/scripts/content/samples/SampleContent.cs` with one valid test mission and all referenced definitions. Add broken sample helpers in test code or in `godot/scripts/content/samples/BrokenSampleContent.cs` if shared fixtures reduce duplication.
7. Add a test project:
dotnet new xunit -n SideScrollerGame.Content.Tests -o tests\SideScrollerGame.Content.Tests
dotnet sln SideScrollerGame.sln add tests\SideScrollerGame.Content.Tests\SideScrollerGame.Content.Tests.csproj
dotnet add tests\SideScrollerGame.Content.Tests\SideScrollerGame.Content.Tests.csproj reference godot\SideScrollerGame.Godot.csproj
If referencing the Godot project makes `dotnet test` fail because of Godot-specific build behavior, create `src\SideScrollerGame.Content\SideScrollerGame.Content.csproj`, move the pure content files there, reference that project from both the Godot project and the test project, and record the reason in `Surprises & Discoveries` and `Decision Log`.
8. Add tests for validation and registry lookup behavior. Use test names that describe the expected behavior, such as `SampleContent_ValidatesWithoutErrors` and `Validate_ClusterWithMissingEnemyType_ReportsMissingEnemy`.
9. Add `godot/scenes/debug/ContentBrowser.tscn` and `godot/scripts/debug/ContentBrowserController.cs`. The browser should display all ids from `SampleContent.CreateRegistry()` and validation messages from `ContentValidator`.
10. Extend debug boot:
godot/scripts/debug/DebugBootMode.cs
godot/scripts/bootstrap/GameRoot.cs
godot/scenes/bootstrap/GameRoot.tscn
Add the `ContentBrowser` enum value, export and wire `ContentBrowserScene`, and load it when requested by `--debug-boot=content-browser`.
11. Format touched C# files. Include every C# file touched by this slice. Use separate path arguments because quoting a semicolon-separated list can be interpreted as one path by JetBrains Cleanup Code:
jb cleanupcode --build=False godot\scripts\content\ContentRegistry.cs godot\scripts\content\definitions\MissionDefinition.cs godot\scripts\content\validation\ContentValidator.cs godot\scripts\content\samples\SampleContent.cs godot\scripts\debug\ContentBrowserController.cs godot\scripts\debug\DebugBootMode.cs godot\scripts\bootstrap\GameRoot.cs tests\SideScrollerGame.Content.Tests\ContentValidationTests.cs
Adjust the file list to include the real touched files. If `jb cleanupcode` is unavailable, record the exact error in `Surprises & Discoveries`, keep formatting manually consistent, and continue validation.
12. Validate the slice:
dotnet test SideScrollerGame.sln
dotnet build SideScrollerGame.sln
.\godot --headless --path godot --build-solutions --quit
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Expected result: tests pass, the solution builds with 0 errors, the Godot solution build exits without a stuck process, and the content browser prints loaded ids plus `Content validation succeeded`.
13. Check the diff:
git status --short
git diff -- SLICE2.MD godot/scripts/content godot/scripts/debug godot/scripts/bootstrap godot/scenes/debug godot/scenes/bootstrap SideScrollerGame.sln tests src
Expected result: the diff includes only Slice 2 files and the minimal solution/project updates required for tests. User changes outside the slice must not be reverted.
14. Commit this slice after validation:
git add SLICE2.MD godot/scripts/content godot/scripts/debug godot/scripts/bootstrap godot/scenes/debug godot/scenes/bootstrap SideScrollerGame.sln tests src
git commit -m "Add core content definitions"
If `src` is not created, omit it. If `SideScrollerGame.sln` is dirty from user work unrelated to the test project addition, stage only the hunks that add the test project.
## Validation and Acceptance
This slice is accepted when the following observable behaviors are true.
Running content tests succeeds:
dotnet test SideScrollerGame.sln
Expected final output should include a passed test summary and no failed tests. The exact number of tests may change, but it should include tests for valid sample content, missing references, duplicate ids, invalid difficulty multipliers, invalid behavior tracks, and registry lookup.
Building from the repository root succeeds:
dotnet build SideScrollerGame.sln
Expected final lines include:
Build succeeded.
0 Error(s)
Godot can build the C# solution and exit:
.\godot --headless --path godot --build-solutions --quit
The command should exit without a stuck Godot process. If it prints warnings that do not fail the process, record them in this plan.
The content browser can be booted directly and used as a validation smoke test:
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Expected output includes:
Debug boot: ContentBrowser
Loaded content definitions:
mission.test
difficulty.normal
enemy.serial
cluster.opening
weapon.primary.basic
Content validation succeeded
Opening the editor should still show the project and root scene:
.\godot --editor --path godot
In the editor, running with no command-line arguments should still show the menu placeholder. Running with `-- --debug-boot=content-browser` should show the content browser with loaded ids and validation status. Running with `-- --debug-boot=smoke --seed=12345` should still print `Smoke scene loaded` and exit in headless mode.
## Idempotence and Recovery
The implementation is mostly additive and safe to repeat. Creating directories that already exist should do nothing. Re-running `dotnet test`, `dotnet build`, Godot solution build, and content browser smoke boot should produce the same result.
If test project creation fails because files already exist, inspect `tests/SideScrollerGame.Content.Tests/SideScrollerGame.Content.Tests.csproj` and continue from the existing project instead of deleting it. If the solution already contains the test project, do not add it twice.
If referencing `godot/SideScrollerGame.Godot.csproj` from tests causes restore or build problems, move pure content definitions into a plain class library under `src/SideScrollerGame.Content/` and reference that library from both the Godot project and tests. This is a safe course change because it keeps Godot scene scripts in the Godot project while making pure rules easy to test.
If `.\godot --headless --path godot --build-solutions --quit` hangs, stop only the stuck Godot process whose command line contains this repository path and this exact command. Record the command and process details in `Surprises & Discoveries`. Validate with `dotnet build SideScrollerGame.sln` plus the content browser smoke boot before continuing.
If user changes appear in files outside this slice, leave them untouched. If user changes conflict with `SideScrollerGame.sln`, `godot/SideScrollerGame.Godot.csproj`, or `godot/project.godot`, read the current file and merge only the smallest required change.
Do not use `git restore`, `git checkout --`, reset commands, or equivalent rollback commands to discard local changes. If a generated file is unwanted, leave it unstaged unless the user explicitly asks for cleanup.
## Artifacts and Notes
Current project shape before Slice 2 implementation:
godot\project.godot
godot\SideScrollerGame.Godot.csproj
godot\scenes\bootstrap\GameRoot.tscn
godot\scenes\debug\SmokeScene.tscn
godot\scenes\menu\MenuPlaceholder.tscn
godot\scripts\bootstrap\GameRoot.cs
godot\scripts\debug\DebugBootMode.cs
godot\scripts\debug\DebugOverlay.cs
godot\scripts\debug\DebugSettings.cs
godot\scripts\debug\SmokeSceneController.cs
godot\scripts\menu\MenuPlaceholder.cs
Current successful Slice 1 smoke command:
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Debug boot: Smoke
Seed: 12345
Smoke scene loaded
The content browser output after this slice should resemble:
Debug boot: ContentBrowser
Seed: 1
Loaded content definitions:
camera.test.path
cluster.opening
collectible.points.small
collectible.squadron.orbit
difficulty.easy
difficulty.hard
difficulty.normal
enemy.parallel
enemy.serial
layer.background.stars
layer.foreground.clouds
mission.test
squadron.orbit
weapon.primary.basic
weapon.secondary.vertical
weapon.special.bomb
Content validation succeeded
## Interfaces and Dependencies
The project uses Godot 4.5.1 .NET through `Godot.NET.Sdk/4.5.1` and targets `net8.0` for desktop builds. New Godot-facing scripts must use the `Godot` namespace and partial Godot classes where required by the engine. Pure content model types should avoid Godot dependencies unless an engine type clearly saves complexity.
At the end of this slice, these public C# types should exist. The exact supporting fields may be adjusted during implementation, but the names, responsibilities, and validation behaviors should remain stable.
In `godot/scripts/content/ContentRegistry.cs`:
namespace SideScrollerGame.Content;
public sealed class ContentRegistry
{
public IReadOnlyDictionary<string, MissionDefinition> Missions { get; }
public IReadOnlyDictionary<string, DifficultyDefinition> Difficulties { get; }
public IReadOnlyDictionary<string, CameraPathDefinition> CameraPaths { get; }
public IReadOnlyDictionary<string, LevelLayerDefinition> LevelLayers { get; }
public IReadOnlyDictionary<string, EnemyTypeDefinition> EnemyTypes { get; }
public IReadOnlyDictionary<string, EnemyBehaviorDefinition> EnemyBehaviors { get; }
public IReadOnlyDictionary<string, EnemyClusterDefinition> EnemyClusters { get; }
public IReadOnlyDictionary<string, CollectibleDefinition> Collectibles { get; }
public IReadOnlyDictionary<string, WeaponDefinition> Weapons { get; }
public IReadOnlyDictionary<string, SpecialWeaponDefinition> SpecialWeapons { get; }
public IReadOnlyDictionary<string, SquadronMateTypeDefinition> SquadronMateTypes { get; }
public IEnumerable<string> AllDefinitionIds();
}
In `godot/scripts/content/validation/ContentValidator.cs`:
namespace SideScrollerGame.Content.Validation;
public sealed class ContentValidator
{
public ContentValidationResult Validate(ContentRegistry registry);
}
In `godot/scripts/content/validation/ContentValidationResult.cs`:
namespace SideScrollerGame.Content.Validation;
public sealed class ContentValidationResult
{
public IReadOnlyList<ContentValidationMessage> Messages { get; }
public bool HasErrors { get; }
}
In `godot/scripts/content/samples/SampleContent.cs`:
namespace SideScrollerGame.Content.Samples;
public static class SampleContent
{
public static ContentRegistry CreateRegistry();
}
In `godot/scripts/debug/ContentBrowserController.cs`:
namespace SideScrollerGame.Debug;
public partial class ContentBrowserController : Control
{
public override void _Ready();
}
The content browser controller must print validation results and quit when headless and `--content-validate-only` is present in `OS.GetCmdlineUserArgs()`.
In `godot/scripts/debug/DebugBootMode.cs`, extend the enum to include:
ContentBrowser
In `godot/scripts/bootstrap/GameRoot.cs`, extend boot loading so:
--debug-boot=content-browser
loads `res://scenes/debug/ContentBrowser.tscn`.
Revision note 2026-04-21: Created this ExecPlan from the current Slice 1 project shell. The plan scopes Slice 2 to pure data definitions, validation, sample content, tests, and an in-engine content browser smoke path while leaving gameplay runtime systems to later slices.

480
SLICE3.MD Normal file
View File

@@ -0,0 +1,480 @@
# Add Debug Foundation
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
This plan follows `PLANS.md` in the repository root. It is intentionally self-contained so a developer with only this repository and this file can implement Slice 3 without reading prior chat history.
## Purpose / Big Picture
After this slice, the project will have shared debug tools that later hero, weapon, enemy, cluster, mission, boss, and highscore slices can reuse. A developer will be able to boot directly into a debug sandbox, pause and resume time, step one frame, change time scale, switch difficulty, set seed, toggle test flags, request actor spawns by content id, jump to a timeline marker, restart the sandbox, reload the scene, and see the active debug state in an overlay.
The observable result is concrete: from `D:\Code\zfxaction26_1`, `dotnet test SideScrollerGame.sln` validates the debug command service, `dotnet build SideScrollerGame.sln` succeeds, and `.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333` boots a Godot debug sandbox, runs a scripted sequence of debug commands, prints the commands and resulting state, prints `Debug foundation smoke succeeded`, and exits with code 0.
This slice does not implement the real hero, weapons, enemies, clusters, or mission runner. It creates the control surface and fake sandbox targets that later slices can replace with real gameplay handlers while keeping the same debug command names and fast testing workflow.
## Progress
- [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.
- [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
- Observation: The active Godot project is under `godot/`, not at the repository root.
Evidence: `godot/project.godot` is the configured project file, and all successful Godot commands use `.\godot --path godot ...` from `D:\Code\zfxaction26_1`.
- Observation: The current debug overlay only shows boot mode, seed, scene id, and debug build state.
Evidence: `godot/scripts/debug/DebugOverlay.cs` contains `SetStatus(DebugSettings settings, string loadedSceneId)` and a single label.
- Observation: The current root boot modes are `Menu`, `Smoke`, and `ContentBrowser`.
Evidence: `godot/scripts/debug/DebugBootMode.cs` contains those three enum values, and `godot/scripts/bootstrap/GameRoot.cs` switches between menu, smoke, and content browser scenes.
- 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.
Rationale: Command validity, flags, selected difficulty, time scale values, spawn requests, and marker jumps are pure rules that can be tested quickly with `dotnet test`. Godot-specific work such as pausing the scene tree and reloading scenes belongs in the node wrapper.
Date/Author: 2026-04-21 / Codex.
- Decision: Use one shared debug command service instead of wiring shortcuts directly inside the sandbox scene.
Rationale: Later slices need the same commands from keyboard shortcuts, clickable debug UI, tests, future automation, and gameplay sandboxes. A shared service avoids duplicating behavior in every scene.
Date/Author: 2026-04-21 / Codex.
- Decision: Make Slice 3 prove spawn and timeline commands with fake sandbox objects.
Rationale: Real enemies, projectiles, clusters, and mission timeline systems do not exist yet. A fake actor and fake marker jump still prove the debug command surface and preserve the same handler interfaces for later real systems.
Date/Author: 2026-04-21 / Codex.
- Decision: Add `DebugSandbox` as a boot mode instead of expanding `ContentBrowser`.
Rationale: The content browser is for inspecting definitions. The debug sandbox is for exercising runtime commands. Keeping them separate prevents the content browser from becoming a mixed-purpose tool.
Date/Author: 2026-04-21 / Codex.
## Outcomes & Retrospective
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
The repository root is `D:\Code\zfxaction26_1`. The Godot project root is `D:\Code\zfxaction26_1\godot`. The repo-local wrapper `D:\Code\zfxaction26_1\godot.cmd` launches Godot 4.5.1 .NET, so Godot commands are run from the repository root with `.\godot --path godot ...`.
Current important files are:
- `godot/project.godot`: Godot project configuration. It sets `run/main_scene="res://scenes/bootstrap/GameRoot.tscn"`, the C# assembly name, and input actions for movement, primary fire, secondary fire, special fire, pause, debug overlay, and quick restart.
- `SideScrollerGame.sln`: Visual Studio solution. It contains the Godot C# project and the `tests/SideScrollerGame.Content.Tests` xUnit project.
- `godot/scripts/bootstrap/GameRoot.cs`: root scene script. It reads `DebugSettings`, seeds Godot randomness, prints boot mode and seed, and loads a scene based on `DebugBootMode`.
- `godot/scenes/bootstrap/GameRoot.tscn`: main scene. It wires `MenuScene`, `SmokeScene`, `ContentBrowserScene`, and a `DebugOverlay` child.
- `godot/scripts/debug/DebugBootMode.cs`: debug boot enum. It currently contains `Menu`, `Smoke`, and `ContentBrowser`.
- `godot/scripts/debug/DebugSettings.cs`: reads `--debug-boot=<value>` and `--seed=<integer>` from user command-line arguments. It already supports hyphenated boot names such as `content-browser`.
- `godot/scripts/debug/DebugOverlay.cs`: small overlay label. It must become a reusable debug status overlay in this slice.
- `godot/scripts/debug/ContentBrowserController.cs`: existing debug content browser that can headlessly validate sample content.
- `godot/scripts/content/samples/SampleContent.cs`: theme-neutral sample content. Slice 3 should use it to validate difficulty ids, actor definition ids, and mission marker names.
- `tests/SideScrollerGame.Content.Tests/`: xUnit tests. Slice 3 can add debug tests here or rename the project later, but the simplest implementation is to add debug tests to this existing test project.
Definitions used in this plan:
- A debug command is a named action used only during development, such as `Pause`, `FrameStep`, `SetTimeScale`, `SpawnActor`, or `JumpToMarker`.
- A debug command service is a C# object that receives debug commands, updates debug state, and notifies registered handlers. A handler is a callback function that a scene provides when it knows how to do a command-specific action, such as spawning a fake actor in the sandbox.
- Debug state is the current shared debug information, such as whether the game is paused, the selected difficulty, current time scale, random seed, active timeline marker, and toggled flags.
- A debug sandbox is a Godot scene made only for development. It contains visible placeholder nodes and buttons so commands can be tested without real gameplay systems.
- A frame step means advancing the game by one rendered/process frame while the game is otherwise paused.
- Time scale means Godot's global simulation speed multiplier. `1.0` is normal speed, `0.5` is half speed, and `2.0` is double speed.
- Headless mode means Godot runs without opening a window. This plan uses headless mode to prove the debug sandbox from the command line.
## Plan of Work
First, add pure debug command types under `godot/scripts/debug/commands/`. Add `DebugCommandId`, an enum for commands such as `ToggleOverlay`, `Pause`, `Resume`, `TogglePause`, `FrameStep`, `SetTimeScale`, `ReloadScene`, `RestartMission`, `SetDifficulty`, `SetSeed`, `SpawnActor`, `JumpToMarker`, `ToggleCollisionShapes`, `ToggleGameplayBounds`, `ToggleInvulnerability`, `ToggleInfiniteSpecialAmmo`, and `ToggleNoEnemyFire`. Add `DebugCommandResult`, a small result object with `Succeeded`, `Message`, and optional `CommandId`. Add `DebugRuntimeState`, a plain C# class or record that stores overlay visibility, pause state, time scale, active difficulty id, seed, last spawned actor id, spawned actor count, current marker id, collision shape visibility flag, gameplay bounds flag, invulnerability flag, infinite special ammo flag, no enemy fire flag, reload request count, restart request count, and frame step request count.
Next, add `DebugCommandService` as a pure C# service in `godot/scripts/debug/commands/`. It should accept a `ContentRegistry` in its constructor so it can validate difficulty ids, actor ids, and mission marker ids against `SampleContent.CreateRegistry()`. It should expose `DebugRuntimeState State`, `DebugCommandResult Execute(DebugCommandId commandId, string? argument = null)`, and events such as `StateChanged` and `CommandExecuted`. It should allow handler registration for sandbox-specific actions: a spawn handler for actor ids, a timeline jump handler for marker ids, a reload handler, and a restart handler. If no handler is registered, the command should still update the request count and return a clear message rather than crashing. Unknown difficulty ids, unknown actor ids, and unknown marker ids should fail with clear messages.
Then add unit tests in `tests/SideScrollerGame.Content.Tests/DebugCommandServiceTests.cs`. These tests should cover at least: pause/resume/toggle pause updates state, time scale accepts the supported values `0.25`, `0.5`, `1`, `2`, and `4`, invalid time scale fails, difficulty can switch to `difficulty.hard`, missing difficulty fails, seed can be set, toggle flags work, spawn actor validates `enemy.serial`, missing actor fails, marker jump validates `cluster.opening`, missing marker fails, restart request increments the restart counter, and command events fire. These tests should not launch Godot.
Next, add a Godot wrapper node `godot/scripts/debug/DebugCommandNode.cs`. It should extend `Node`, own one `DebugCommandService`, and apply engine-level effects. When the service state says paused, set `GetTree().Paused`. When time scale changes, set `Engine.TimeScale`. When frame step is requested, briefly unpause for one process frame and then pause again. When reload is requested, call `GetTree().ReloadCurrentScene()` unless the current scene is the root scene and the command came from the headless smoke script; in the smoke script, record the reload request without disrupting the script. Keep the wrapper small and let the pure service own validation and state.
Update `godot/scripts/bootstrap/GameRoot.cs` and `godot/scenes/bootstrap/GameRoot.tscn` so the root scene owns one `DebugCommandNode` child and passes the debug settings seed into it. Expose the command node to loaded scenes by node path, for example `/root/GameRoot/DebugCommandNode`. Do not make it a global autoload in this slice; the root already exists and can own shared runtime services.
Upgrade `godot/scripts/debug/DebugOverlay.cs`. It should be toggleable and should display active boot mode, loaded scene id, seed, active difficulty, paused state, time scale, current marker, spawned actor count, and flags for invulnerability, infinite special ammo, no enemy fire, collision shapes, and gameplay bounds. Add a method such as `Bind(DebugCommandService service, DebugSettings settings, string loadedSceneId)`. The overlay should subscribe to `StateChanged` and refresh itself. Keep `SetStatus` only if it remains useful as a compatibility wrapper.
Add a clickable debug panel for the sandbox. Create `godot/scripts/debug/DebugPanelController.cs` and use a simple `Control` node in the sandbox scene. The panel should have buttons or option controls for pause/resume, frame step, time scale choices, difficulty choices, spawn `enemy.serial`, spawn `enemy.parallel`, jump `intro`, jump `cluster.opening`, toggle invulnerability, toggle infinite special ammo, toggle no enemy fire, toggle collision shapes, toggle gameplay bounds, restart sandbox, and reload scene. If building all buttons as `.tscn` children is verbose, the controller may create buttons in `_Ready()` in code for this slice.
Add `godot/scenes/debug/DebugSandbox.tscn` and `godot/scripts/debug/DebugSandboxController.cs`. The sandbox should be a `Node2D` or `Control` scene with a title, a simple playfield area, the debug panel, and a log label. It should find the root `DebugCommandNode`, register spawn, marker jump, reload, and restart handlers, and update the log whenever a command executes. The spawn handler should create a visible placeholder node with the requested actor id as text and increment a visible count. The marker jump handler should update a visible marker label. The restart handler should clear spawned actors and reset the marker label. The reload handler may just log in the sandbox because the root command wrapper owns actual scene reload behavior.
Add headless smoke support to the sandbox. If Godot is headless and the user command-line contains `--debug-script=foundation-smoke`, the sandbox controller should run a deterministic command sequence after one process frame: pause, set time scale to `0.5`, set difficulty to `difficulty.hard`, set seed to the command-line seed, spawn `enemy.serial`, jump to `cluster.opening`, toggle invulnerability, toggle infinite special ammo, toggle no enemy fire, toggle collision shapes, toggle gameplay bounds, restart sandbox, and finally spawn `enemy.parallel`. It should verify the resulting state and printed log, print `Debug foundation smoke succeeded`, and quit with exit code 0. If any command fails unexpectedly, print `Debug foundation smoke failed` and quit with exit code 1.
Update debug boot wiring. Add `DebugSandbox` to `DebugBootMode`. Add an exported `PackedScene? DebugSandboxScene` to `GameRoot`. Add the `res://scenes/debug/DebugSandbox.tscn` reference to `godot/scenes/bootstrap/GameRoot.tscn`. Update `GameRoot.LoadBootScene` so `--debug-boot=debug-sandbox` loads the sandbox.
Update input actions in `godot/project.godot`. Keep existing actions and add these actions: `debug_pause`, `debug_frame_step`, `debug_time_slower`, `debug_time_faster`, `debug_spawn_actor`, `debug_jump_marker`, `debug_toggle_invulnerability`, `debug_toggle_infinite_special_ammo`, `debug_toggle_no_enemy_fire`, `debug_toggle_collision_shapes`, and `debug_toggle_gameplay_bounds`. The sandbox controller should handle these actions through `_UnhandledInput` and call `DebugCommandNode.Execute(...)` so keyboard and clickable panel use the same service.
Finally, update this ExecPlan as work proceeds. Do not edit `DESIGN.md` unless the user explicitly asks for design changes. If `SideScrollerGame.sln`, `godot/SideScrollerGame.Godot.csproj`, or test project files have unrelated user edits, preserve them and stage only Slice 3 changes.
## Concrete Steps
Run all commands from `D:\Code\zfxaction26_1`.
1. Confirm the current state before editing:
git status --short
dotnet test SideScrollerGame.sln
dotnet build SideScrollerGame.sln
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Expected result: the worktree should be clean unless the user has made new edits. Tests should report 8 or more passing tests. The content browser should print loaded definitions and `Content validation succeeded`.
2. Create the debug command folder:
godot/scripts/debug/commands
Creating a directory that already exists is safe.
3. Add pure debug command files under `godot/scripts/debug/commands/`:
DebugCommandId.cs
DebugCommandResult.cs
DebugRuntimeState.cs
DebugCommandService.cs
Keep these files free of Godot node dependencies. They may use `System`, `System.Collections.Generic`, and the content registry types.
4. Add tests in:
tests/SideScrollerGame.Content.Tests/DebugCommandServiceTests.cs
Use `SampleContent.CreateRegistry()` as the registry input. The tests must prove the service works without launching Godot.
5. Add Godot debug runtime scripts:
godot/scripts/debug/DebugCommandNode.cs
godot/scripts/debug/DebugPanelController.cs
godot/scripts/debug/DebugSandboxController.cs
The command node bridges service state to Godot pause and time scale. The panel and sandbox call into the command node.
6. Add the sandbox scene:
godot/scenes/debug/DebugSandbox.tscn
The scene should contain the sandbox controller, a label for current marker, a label or container for spawned actors, a log label, and a simple panel node for debug buttons.
7. Update existing debug boot and root files:
godot/scripts/debug/DebugBootMode.cs
godot/scripts/bootstrap/GameRoot.cs
godot/scripts/debug/DebugOverlay.cs
godot/scenes/bootstrap/GameRoot.tscn
Wire the `DebugSandbox` boot mode, add the command node child, bind the overlay to the command service, and preserve `Menu`, `Smoke`, and `ContentBrowser` boot behavior.
8. Update `godot/project.godot` input actions. Add only missing debug actions. Preserve existing actions and settings.
9. Format touched C# files. Include every C# file touched by this slice. Use separate path arguments:
jb cleanupcode --build=False godot\scripts\debug\commands\DebugCommandId.cs godot\scripts\debug\commands\DebugCommandResult.cs godot\scripts\debug\commands\DebugRuntimeState.cs godot\scripts\debug\commands\DebugCommandService.cs godot\scripts\debug\DebugCommandNode.cs godot\scripts\debug\DebugPanelController.cs godot\scripts\debug\DebugSandboxController.cs godot\scripts\debug\DebugOverlay.cs godot\scripts\debug\DebugBootMode.cs godot\scripts\bootstrap\GameRoot.cs tests\SideScrollerGame.Content.Tests\DebugCommandServiceTests.cs
Adjust the file list to include the real touched files. If `jb cleanupcode` is unavailable, record the exact error in `Surprises & Discoveries`, keep formatting manually consistent, and continue validation.
10. Validate the slice:
dotnet test SideScrollerGame.sln
dotnet build SideScrollerGame.sln
.\godot --headless --path godot --build-solutions --quit
.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Expected result: tests pass, the solution builds with 0 errors, the Godot solution build exits without a stuck process, the debug sandbox smoke prints `Debug foundation smoke succeeded`, the content browser still validates sample content, and the existing smoke scene still prints `Smoke scene loaded`.
11. Check the diff:
git status --short
git diff -- SLICE3.MD godot/project.godot godot/scripts/debug godot/scripts/bootstrap godot/scenes/debug godot/scenes/bootstrap tests
Expected result: the diff includes only Slice 3 files and minimal edits to shared debug/root files. User changes outside the slice must not be reverted.
12. Commit this slice after validation:
git add SLICE3.MD godot/project.godot godot/scripts/debug godot/scripts/bootstrap godot/scenes/debug godot/scenes/bootstrap tests
git commit -m "Add debug foundation"
## Validation and Acceptance
This slice is accepted when the following observable behaviors are true.
Running tests succeeds:
dotnet test SideScrollerGame.sln
Expected final output should include a passed test summary and no failed tests. The exact number of tests may change, but it should include the existing content validation tests plus new debug command service tests.
Building from the repository root succeeds:
dotnet build SideScrollerGame.sln
Expected final lines include:
Build succeeded.
0 Error(s)
Godot can build the C# solution and exit:
.\godot --headless --path godot --build-solutions --quit
The command should exit without a stuck Godot process. If it prints warnings that do not fail the process, record them in this plan.
The debug sandbox can be booted directly and can exercise the command service headlessly:
.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333
Expected output includes:
Debug boot: DebugSandbox
Seed: 333
Debug foundation smoke loaded
Command executed: Pause
Command executed: SetTimeScale 0.5
Command executed: SetDifficulty difficulty.hard
Actor spawned: enemy.serial
Timeline marker: cluster.opening
Command executed: RestartMission
Actor spawned: enemy.parallel
Debug foundation smoke succeeded
Existing direct boots must still work:
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Expected output includes:
Content validation succeeded
And:
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Expected output includes:
Debug boot: Smoke
Seed: 12345
Smoke scene loaded
Opening the editor should show the project and root scene:
.\godot --editor --path godot
In the editor, running with `-- --debug-boot=debug-sandbox` should show the sandbox. The overlay should be visible by default, the buttons should change the overlay state, keyboard shortcuts should call the same commands, spawned fake actors should appear in the sandbox, restart should clear them, and the reload command should reload the scene.
## Idempotence and Recovery
The implementation is mostly additive and safe to repeat. Creating directories that already exist should do nothing. Re-running tests, builds, and smoke commands should produce the same result.
If `godot/project.godot` already contains one of the planned debug input actions, keep the existing action and add only missing actions. Do not duplicate action blocks.
If `.\godot --headless --path godot --build-solutions --quit` generates new `.cs.uid` files for scripts, keep them if Godot generated them during the project scan. Do not hand-edit generated UID values.
If the debug sandbox smoke command hangs, inspect running processes and stop only the Godot process whose command line contains `--debug-boot=debug-sandbox --debug-script=foundation-smoke` and this repository path. Record the command and process detail in `Surprises & Discoveries`, then fix the smallest affected script.
If frame-step behavior is unreliable in headless mode, keep unit tests focused on the frame step request counter and validate the visual one-frame advance manually in the editor. Record the limitation in `Surprises & Discoveries` and `Outcomes & Retrospective`.
If user changes appear in files outside this slice, leave them untouched. If user changes conflict with `godot/project.godot`, `GameRoot.tscn`, or shared debug scripts, read the file and merge only the smallest required change.
Do not use `git restore`, `git checkout --`, reset commands, or equivalent rollback commands to discard local changes. If generated files are unwanted, leave them unstaged unless the user explicitly asks for cleanup.
## Artifacts and Notes
Current successful Slice 2 commands before Slice 3 implementation:
dotnet test SideScrollerGame.sln
Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Debug boot: ContentBrowser
Seed: 1
Loaded content definitions:
behavior.enemy.parallel
behavior.enemy.serial
camera.test.path
cluster.opening
collectible.points.small
collectible.squadron.orbit
difficulty.easy
difficulty.hard
difficulty.normal
enemy.parallel
enemy.serial
layer.background.stars
layer.foreground.clouds
mission.test
squadron.orbit
weapon.primary.basic
weapon.secondary.vertical
weapon.special.bomb
Content validation succeeded
The debug sandbox smoke output after this slice should resemble:
Debug boot: DebugSandbox
Seed: 333
Debug foundation smoke loaded
Command executed: Pause
Command executed: SetTimeScale 0.5
Command executed: SetDifficulty difficulty.hard
Command executed: SpawnActor enemy.serial
Actor spawned: enemy.serial
Command executed: JumpToMarker cluster.opening
Timeline marker: cluster.opening
Command executed: ToggleInvulnerability
Command executed: ToggleInfiniteSpecialAmmo
Command executed: ToggleNoEnemyFire
Command executed: ToggleCollisionShapes
Command executed: ToggleGameplayBounds
Command executed: RestartMission
Command executed: SpawnActor enemy.parallel
Actor spawned: enemy.parallel
Debug foundation smoke succeeded
## Interfaces and Dependencies
The project uses Godot 4.5.1 .NET through `Godot.NET.Sdk/4.5.1` and targets `net8.0` for desktop builds. New Godot-facing scripts must use the `Godot` namespace and partial Godot classes where required by the engine. Pure debug command types should avoid Godot node dependencies so xUnit tests can run without launching the engine.
At the end of this slice, these public C# types should exist. The exact supporting members may be adjusted during implementation, but the names, responsibilities, and observable behaviors should remain stable.
In `godot/scripts/debug/commands/DebugCommandId.cs`:
namespace SideScrollerGame.Debug.Commands;
public enum DebugCommandId
{
ToggleOverlay,
Pause,
Resume,
TogglePause,
FrameStep,
SetTimeScale,
ReloadScene,
RestartMission,
SetDifficulty,
SetSeed,
SpawnActor,
JumpToMarker,
ToggleCollisionShapes,
ToggleGameplayBounds,
ToggleInvulnerability,
ToggleInfiniteSpecialAmmo,
ToggleNoEnemyFire
}
In `godot/scripts/debug/commands/DebugRuntimeState.cs`:
namespace SideScrollerGame.Debug.Commands;
public sealed class DebugRuntimeState
{
public bool OverlayVisible { get; }
public bool IsPaused { get; }
public double TimeScale { get; }
public string ActiveDifficultyId { get; }
public int Seed { get; }
public string CurrentMarkerId { get; }
public string LastSpawnedActorId { get; }
public int SpawnedActorCount { get; }
public bool ShowCollisionShapes { get; }
public bool ShowGameplayBounds { get; }
public bool Invulnerable { get; }
public bool InfiniteSpecialAmmo { get; }
public bool NoEnemyFire { get; }
public int ReloadSceneRequestCount { get; }
public int RestartMissionRequestCount { get; }
public int FrameStepRequestCount { get; }
}
The implementation may use mutable properties if that keeps the code simple, but callers should treat the state as read-only outside the service.
In `godot/scripts/debug/commands/DebugCommandService.cs`:
namespace SideScrollerGame.Debug.Commands;
public sealed class DebugCommandService
{
public DebugCommandService(ContentRegistry registry, int seed);
public DebugRuntimeState State { get; }
public DebugCommandResult Execute(DebugCommandId commandId, string? argument = null);
public event Action<DebugRuntimeState>? StateChanged;
public event Action<DebugCommandResult>? CommandExecuted;
}
The service should validate difficulty ids against `registry.Difficulties`, actor ids against `registry.EnemyTypes`, and marker ids against mission `mission.test` timeline markers. If a later slice changes the active mission, it can extend the service to select a different mission id.
In `godot/scripts/debug/DebugCommandNode.cs`:
namespace SideScrollerGame.Debug;
public partial class DebugCommandNode : Node
{
public DebugCommandService Service { get; }
public void Initialize(DebugSettings settings);
public DebugCommandResult Execute(DebugCommandId commandId, string? argument = null);
}
In `godot/scripts/debug/DebugOverlay.cs`, add:
public void Bind(DebugCommandService service, DebugSettings settings, string loadedSceneId);
The overlay should refresh when the service state changes and should hide or show itself based on the overlay visibility flag.
In `godot/scripts/debug/DebugSandboxController.cs`:
namespace SideScrollerGame.Debug;
public partial class DebugSandboxController : Node
{
public override void _Ready();
public override void _UnhandledInput(InputEvent @event);
}
The sandbox should use `DebugCommandNode.Execute(...)` for both keyboard and button commands. It should not maintain separate command rules.
In `godot/scripts/debug/DebugBootMode.cs`, extend the enum to include:
DebugSandbox
In `godot/scripts/bootstrap/GameRoot.cs`, extend boot loading so:
--debug-boot=debug-sandbox
loads `res://scenes/debug/DebugSandbox.tscn`.
Revision note 2026-04-21: Created this ExecPlan from the current Slice 2 project state. The plan scopes Slice 3 to shared debug command infrastructure, overlay upgrades, keyboard and clickable sandbox controls, and headless smoke validation while leaving real gameplay systems to later slices.

534
SLICE4.md Normal file
View File

@@ -0,0 +1,534 @@
# Add Hero Runtime
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
This plan follows `PLANS.md` in the repository root. It is intentionally self-contained so a developer with only this repository and this file can implement Slice 4 without reading prior chat history.
## Purpose / Big Picture
After this slice, the project will have the first real gameplay actor: a controllable hero with camera-relative movement bounds, shield charges, retries, death and rebirth rules, points, level-up thresholds, primary weapon slots, secondary weapon selection, special weapon ammo, and mission-persistent inventory state. A developer will be able to boot directly into a hero sandbox, move the hero inside visible bounds, press debug buttons or shortcuts to damage, heal, kill, rebirth, add points, change level, change retries, clear inventory, and see every state change immediately.
The observable result is concrete: from `D:\Code\zfxaction26_1`, `dotnet test SideScrollerGame.sln` validates pure hero rules, `dotnet build SideScrollerGame.sln` succeeds, and `.\godot --headless --path godot -- --debug-boot=hero-sandbox --debug-script=hero-smoke --seed=444` boots a Godot hero sandbox, runs a scripted sequence of hero debug commands, prints the resulting hero state, prints `Hero sandbox smoke succeeded`, and exits with code 0.
This slice does not implement weapon firing, collectibles as physical pickup actors, enemy collisions, squadron mate actors, shop flow, or the mission runner. It creates the hero state model, hero node, sandbox, and debug hooks those later slices will use.
## Progress
- [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) Created this Slice 4 ExecPlan.
- [x] (2026-04-21 20:53Z) Implemented pure hero runtime state and rules.
- [x] (2026-04-21 20:53Z) Added unit tests for hero movement-independent rules.
- [x] (2026-04-21 20:53Z) Added Godot hero actor, hero HUD, and hero sandbox.
- [x] (2026-04-21 20:53Z) Added hero sandbox debug commands, keyboard shortcuts, and headless smoke script.
- [x] (2026-04-21 20:53Z) Wired `HeroSandbox` into debug boot and validated existing boot modes still work.
- [x] (2026-04-21 20:53Z) Ran formatting, tests, Godot smokes, and updated this plan.
- [x] Commit the completed slice.
## Surprises & Discoveries
- Observation: The active Godot project is under `godot/`, not at the repository root.
Evidence: `godot/project.godot` is the configured project file, and all successful Godot commands use `.\godot --path godot ...` from `D:\Code\zfxaction26_1`.
- Observation: The current debug command foundation already supports pause, frame step, time scale, difficulty, seed, fake actor spawning, marker jumps, reload, restart, and debug flags.
Evidence: `godot/scripts/debug/commands/DebugCommandId.cs` and `godot/scripts/debug/commands/DebugCommandService.cs` contain those commands.
- Observation: Sample difficulty definitions already contain hero starting shield charges and retry count.
Evidence: `godot/scripts/content/definitions/DifficultyDefinition.cs` defines `HeroStartingShieldCharges` and `HeroRetryCount`; `godot/scripts/content/samples/SampleContent.cs` sets Normal to 3 shields and 3 retries.
- 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.
- 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: Implement hero gameplay rules as plain C# before Godot node behavior.
Rationale: Shield, retry, death, rebirth, point threshold, inventory slot, and level-up behavior are brittle game rules. They can be tested quickly with `dotnet test` without launching Godot, then driven from the Godot hero actor and sandbox.
Date/Author: 2026-04-21 / Codex.
- Decision: Use `DifficultyDefinition` as the source for starting shield charges and retry count.
Rationale: Difficulty definitions already include those fields and the design says difficulty should influence hero starting survival rules. This avoids duplicating difficulty constants in hero code.
Date/Author: 2026-04-21 / Codex.
- Decision: Treat score points and hero level as run progress that survive death, while collectible-derived inventory resets on death.
Rationale: The design says the hero collects points and levels up from point thresholds, and also says the hero loses all collectibles on death. For this slice, points and level are not treated as collectible inventory, while extra weapon slots, secondary selection, special ammo, and squadron mates are reset by the death penalty. This keeps scoring and level progression stable while preserving the death penalty.
Date/Author: 2026-04-21 / Codex.
- Decision: Rebirth consumes one retry and restores shield charges to the active difficulty starting value.
Rationale: The design gives initial shield charges and retry counts but does not define the exact rebirth shield value. Restoring difficulty starting shields makes the sandbox playable and keeps difficulty visible in hero survival behavior.
Date/Author: 2026-04-21 / Codex.
- Decision: Add `HeroSandbox` as a dedicated boot mode instead of expanding `DebugSandbox`.
Rationale: The debug sandbox tests debug commands generically. The hero sandbox should be focused on movement bounds and hero state transitions so later weapon, collectible, and squadron systems can reuse it without mixing unrelated fake actors.
Date/Author: 2026-04-21 / Codex.
## Outcomes & Retrospective
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
The repository root is `D:\Code\zfxaction26_1`. The Godot project root is `D:\Code\zfxaction26_1\godot`. The repo-local wrapper `D:\Code\zfxaction26_1\godot.cmd` launches Godot 4.5.1 .NET, so Godot commands are run from the repository root with `.\godot --path godot ...`.
Current important files are:
- `godot/project.godot`: Godot project configuration. It sets `run/main_scene="res://scenes/bootstrap/GameRoot.tscn"`, the C# assembly name, gameplay input actions, and debug input actions.
- `SideScrollerGame.sln`: Visual Studio solution. It contains the Godot C# project and the `tests/SideScrollerGame.Content.Tests` xUnit project.
- `godot/scripts/bootstrap/GameRoot.cs`: root scene script. It reads `DebugSettings`, seeds Godot randomness, initializes `DebugCommandNode`, and loads a scene based on `DebugBootMode`.
- `godot/scenes/bootstrap/GameRoot.tscn`: main scene. It wires the current debug boot scenes and owns `DebugCommandNode` and `DebugOverlay`.
- `godot/scripts/debug/DebugBootMode.cs`: debug boot enum. It currently contains `Menu`, `Smoke`, `ContentBrowser`, and `DebugSandbox`.
- `godot/scripts/debug/DebugCommandNode.cs`: Godot wrapper around `DebugCommandService`. It applies pause, time scale, frame stepping, scene reload, and seed effects.
- `godot/scripts/debug/commands/DebugCommandService.cs`: pure debug command service. It validates difficulty ids, actor ids, and mission marker ids, and exposes command events.
- `godot/scripts/debug/DebugOverlay.cs`: debug overlay label. It currently shows boot, scene, seed, difficulty, pause, time scale, marker, fake spawn count, and debug flags.
- `godot/scripts/content/samples/SampleContent.cs`: theme-neutral sample content. It defines `difficulty.easy`, `difficulty.normal`, `difficulty.hard`, `weapon.primary.basic`, `weapon.secondary.vertical`, `weapon.special.bomb`, and `squadron.orbit`.
- `tests/SideScrollerGame.Content.Tests/`: xUnit tests. Slice 4 should add hero rule tests here to keep the local test path simple.
Definitions used in this plan:
- The hero is the player-controlled ship or avatar. Its final visual theme is unknown, so this slice uses simple placeholder shapes and labels.
- Camera-relative movement bounds are a rectangle on screen, relative to the current camera view, that the hero may not leave. In the sandbox, this rectangle is drawn visibly and does not require a real mission camera path.
- Shield charges are hit protection points. A hit removes one shield charge when at least one charge exists. A hit with zero charges kills the hero unless invulnerability is active.
- Retry count is the number of rebirths still available after death. Rebirth consumes one retry. If the hero dies with no retry available, the state becomes game over.
- Rebirth means returning from a dead state into an alive state inside the same run.
- Mission-persistent inventory state is the hero state that should be carried into the next mission after victory. This slice stores the shape of that state but does not implement mission flow.
- A primary weapon slot is one entry in a fixed-size list. In this slice the hero has two primary slots. The first slot starts with `weapon.primary.basic`; the second starts empty. Later slices can tune the slot count through content.
- A sandbox is a development-only scene for exercising one system quickly. The hero sandbox is not final gameplay UI.
- Headless mode means Godot runs without opening a window. This plan uses headless mode to prove the hero sandbox from the command line.
## Plan of Work
First, add pure hero rule files under `godot/scripts/hero/rules/`. Add `HeroRunState`, a mutable state object or record that stores active difficulty id, level, points, shield charges, retry count, alive/dead/game-over status, primary weapon slots, selected primary weapon slot index, current secondary weapon id, current special weapon id, special ammo, squadron mate type id, squadron mate count, and last state change message. Add `HeroRuleConfig`, a plain object containing primary slot count, base primary weapon id, base secondary weapon id, default special weapon id, special ammo, max squadron mates, and point thresholds. Add `HeroRuleResult`, a small result object with `Succeeded`, `Message`, and optional changed state snapshot. Add `HeroRuntimeService`, a plain service that owns a `HeroRunState` and exposes methods for starting from a difficulty, applying hits, killing, rebirthing, adding points, setting level for debug, adding and removing shield charges, setting retries, selecting the current primary slot, applying primary weapon pickups, applying secondary weapon pickups, setting special weapon and ammo, adding special ammo, setting squadron mate type and count, clearing collectible-derived inventory, and creating a mission persistence snapshot.
The default hero config for this slice should be conservative and theme-neutral: two primary weapon slots, `weapon.primary.basic` in slot 0, empty slot 1, `weapon.secondary.vertical` as the base secondary weapon, `weapon.special.bomb` as the default special weapon for the sandbox, max four squadron mates, and point thresholds of 500, 1500, and 3000 for levels 2, 3, and 4. Level-up should add one shield charge for each level gained. If one point grant crosses multiple thresholds, it should apply every level and every shield gain.
The death and rebirth rules should be explicit. `ApplyHit(invulnerable: false)` reduces shield charges by one if the hero is alive and has shields. If the hero is alive and has zero shields, it sets the hero to dead when retries remain, or game over when no retries remain. `ApplyHit(invulnerable: true)` should produce a successful no-damage result. `Kill()` should force the same death path as a zero-shield hit. `Rebirth()` should only work from the dead state, consume one retry, set the hero alive, restore shield charges from the active difficulty, and clear collectible-derived inventory. If no retries remain, `Rebirth()` should fail and leave game over unchanged.
Next, add tests in `tests/SideScrollerGame.Content.Tests/HeroRuntimeServiceTests.cs`. Use `SampleContent.CreateRegistry()` and `difficulty.normal` for most tests. Cover: start state uses difficulty shields and retries, movement-independent hit rules consume shields before death, death with retries enters dead state, rebirth consumes a retry and restores shields, death with zero retries enters game over, adding points levels up and grants shields, point grants can cross multiple thresholds, primary weapon pickup fills an empty slot before replacing the current selected slot, selected primary slot can be toggled, secondary pickup replaces the current secondary weapon, special ammo changes are capped at or above zero according to the config, squadron mate pickup changes type while preserving count up to four, clearing inventory resets collectible-derived state, and invulnerability prevents damage.
Then add Godot-facing hero scripts and scenes. Create `godot/scripts/hero/HeroActor.cs` and `godot/scenes/hero/Hero.tscn`. `HeroActor` should extend `Node2D`, use existing input actions `move_up`, `move_down`, `move_left`, and `move_right`, move at an exported speed, clamp its global position to an exported `Rect2 PlayBounds`, and expose `SetRuntime(HeroRuntimeService runtime)` and `SetPlayBounds(Rect2 bounds)`. The scene should contain a simple placeholder body such as `Polygon2D` plus a collision or marker node only if useful for debugging. It should not implement weapon firing in this slice.
Add `godot/scripts/hero/HeroStateHudController.cs`. This should be a small `Control` script that displays the active hero state: alive/dead/game over, level, points, next threshold, shields, retries, selected primary slot, primary slot contents, secondary weapon, special weapon ammo, squadron mate type and count, and last state change. Keep the HUD dense and readable. It is a debug tool, not final game UI.
Add `godot/scripts/debug/HeroSandboxController.cs` and `godot/scenes/debug/HeroSandbox.tscn`. The sandbox should contain a visible playfield rectangle, a `HeroActor`, a `HeroStateHudController`, a log label, and a simple debug button panel. The controller should create `HeroRuntimeService` from `SampleContent.CreateRegistry()`, the active difficulty from `DebugCommandNode.Service.State.ActiveDifficultyId`, and the default hero config. It should pass the runtime into `HeroActor` and HUD. It should register or call hero debug commands through the shared `DebugCommandNode` so keyboard, buttons, and headless smoke use the same route.
Extend the debug command layer for hero actions. Add these values to `godot/scripts/debug/commands/DebugCommandId.cs`: `DamageHero`, `HealHero`, `KillHero`, `RebirthHero`, `AddHeroPoints`, `SetHeroLevel`, `AddHeroShield`, `RemoveHeroShield`, `SetHeroRetries`, `TogglePrimaryWeaponSlot`, `ClearHeroInventory`, `GivePrimaryWeapon`, `GiveSecondaryWeapon`, `GiveSpecialAmmo`, and `GiveSquadronMate`. Add a small generic command handler mechanism to `DebugCommandService`, such as `RegisterCommandHandler(DebugCommandId commandId, Func<string?, DebugCommandResult> handler)`, so scene-specific systems can handle hero commands without putting hero state directly into the shared debug service. Existing commands must keep their current behavior and tests. Hero sandbox command handlers should call `HeroRuntimeService`, refresh the HUD, log the result, and return `DebugCommandResult`.
Upgrade `godot/scripts/debug/DebugOverlay.cs` only enough to show a concise hero summary when one is available. Do not make the overlay own hero state. Add a method such as `SetHeroSummary(string summary)` and have the hero sandbox update it after hero state changes. The existing debug overlay text for boot, scene, seed, difficulty, pause, time scale, marker, fake actor count, and flags must remain intact.
Add a clickable hero debug panel in the hero sandbox. Buttons should call the same debug commands as keyboard shortcuts. Include buttons for damage, heal, kill, rebirth, add 100 points, add 500 points, set level 1, set level 4, add shield, remove shield, set retries to 0, set retries to 3, toggle primary slot, give primary `weapon.primary.basic`, give secondary `weapon.secondary.vertical`, give special ammo 3, give squadron mate `squadron.orbit`, clear inventory, toggle invulnerability, pause, frame step, restart sandbox, and reload scene. If authoring all controls in the `.tscn` file is too verbose, the controller may create them in `_Ready()` for this slice.
Add keyboard shortcuts to `godot/project.godot` for the most common hero sandbox commands: `debug_damage_hero`, `debug_kill_hero`, `debug_rebirth_hero`, `debug_add_points`, `debug_add_shield`, `debug_remove_shield`, `debug_toggle_primary_slot`, and `debug_clear_hero_inventory`. Keep existing actions and add only missing ones. In `HeroSandboxController._UnhandledInput`, translate those actions to `DebugCommandNode.Execute(...)`.
Add `HeroSandbox` boot wiring. Update `godot/scripts/debug/DebugBootMode.cs` to include `HeroSandbox`. Update `godot/scripts/bootstrap/GameRoot.cs` to export a `PackedScene? HeroSandboxScene` and load it when the boot mode is `HeroSandbox`. Update `godot/scenes/bootstrap/GameRoot.tscn` to reference `res://scenes/debug/HeroSandbox.tscn`.
Add headless smoke support. If Godot is headless and the user command-line contains `--debug-script=hero-smoke`, `HeroSandboxController` should run a deterministic command sequence after one process frame: set difficulty to `difficulty.normal`, damage the hero once, add 500 points, toggle primary slot, give primary `weapon.primary.basic`, give special ammo 3, give squadron mate `squadron.orbit`, toggle invulnerability, damage the hero once, kill the hero, rebirth the hero, set retries to 0, kill the hero again, verify game over, print a compact state transcript, print `Hero sandbox smoke succeeded`, and quit with exit code 0. If any expected command fails or any expected state is wrong, print `Hero sandbox smoke failed` and quit with exit code 1.
Finally, update this ExecPlan as work proceeds. Do not edit `DESIGN.md` or `CODE.md` as part of this slice unless the implementation reveals a mismatch that must be documented. If `SideScrollerGame.sln`, `godot/SideScrollerGame.Godot.csproj`, or shared debug files have unrelated user edits, preserve them and stage only Slice 4 changes.
## Concrete Steps
Run all commands from `D:\Code\zfxaction26_1`.
1. Confirm the current state before editing:
git status --short
dotnet test SideScrollerGame.sln
dotnet build SideScrollerGame.sln
.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Expected result: the worktree should be clean unless the user has made new edits. Tests should report 22 or more passing tests. The debug sandbox should print `Debug foundation smoke succeeded`, and the content browser should print `Content validation succeeded`.
2. Create hero folders:
godot/scripts/hero
godot/scripts/hero/rules
godot/scenes/hero
Creating directories that already exist is safe.
3. Add pure hero rule files under `godot/scripts/hero/rules/`:
HeroLifeState.cs
HeroRuleConfig.cs
HeroRuleResult.cs
HeroRunState.cs
HeroRuntimeService.cs
HeroMissionSnapshot.cs
Keep these files free of Godot node dependencies. They may use `System`, `System.Collections.Generic`, `System.Linq`, and content registry or definition types.
4. Add tests in:
tests/SideScrollerGame.Content.Tests/HeroRuntimeServiceTests.cs
Use `SampleContent.CreateRegistry()` and the default hero config from the implementation. These tests must not launch Godot.
5. Add Godot hero runtime scripts:
godot/scripts/hero/HeroActor.cs
godot/scripts/hero/HeroStateHudController.cs
godot/scripts/debug/HeroSandboxController.cs
`HeroActor` owns movement and bounds clamping. `HeroStateHudController` displays state. `HeroSandboxController` owns sandbox wiring, debug command handlers, buttons, keyboard shortcuts, restart, and headless smoke.
6. Add scenes:
godot/scenes/hero/Hero.tscn
godot/scenes/debug/HeroSandbox.tscn
Use built-in Godot nodes and placeholder shapes only. No art assets are required for this slice.
7. Extend shared debug command files:
godot/scripts/debug/commands/DebugCommandId.cs
godot/scripts/debug/commands/DebugCommandService.cs
godot/scripts/debug/commands/DebugRuntimeState.cs if needed
godot/scripts/debug/DebugOverlay.cs
Add hero command ids, generic scene command handler registration, and optional hero summary display. Preserve all Slice 3 command behavior.
8. Wire boot and input:
godot/scripts/debug/DebugBootMode.cs
godot/scripts/bootstrap/GameRoot.cs
godot/scenes/bootstrap/GameRoot.tscn
godot/project.godot
Add `HeroSandbox`, the exported scene reference, the boot switch branch, and missing hero debug input actions.
9. Format touched C# files. Include every C# file touched by this slice. Use separate path arguments:
jb cleanupcode --build=False godot\scripts\hero\rules\HeroLifeState.cs godot\scripts\hero\rules\HeroRuleConfig.cs godot\scripts\hero\rules\HeroRuleResult.cs godot\scripts\hero\rules\HeroRunState.cs godot\scripts\hero\rules\HeroRuntimeService.cs godot\scripts\hero\rules\HeroMissionSnapshot.cs godot\scripts\hero\HeroActor.cs godot\scripts\hero\HeroStateHudController.cs godot\scripts\debug\HeroSandboxController.cs godot\scripts\debug\commands\DebugCommandId.cs godot\scripts\debug\commands\DebugCommandService.cs godot\scripts\debug\DebugOverlay.cs godot\scripts\debug\DebugBootMode.cs godot\scripts\bootstrap\GameRoot.cs tests\SideScrollerGame.Content.Tests\HeroRuntimeServiceTests.cs
Adjust the file list to include the real touched files. If `jb cleanupcode` is unavailable, record the exact error in `Surprises & Discoveries`, keep formatting manually consistent, and continue validation.
10. Validate the slice:
dotnet test SideScrollerGame.sln
dotnet build SideScrollerGame.sln
.\godot --headless --path godot --build-solutions --quit
.\godot --headless --path godot -- --debug-boot=hero-sandbox --debug-script=hero-smoke --seed=444
.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Expected result: tests pass, the solution builds with 0 errors, Godot solution build exits, the hero sandbox smoke prints `Hero sandbox smoke succeeded`, the debug sandbox still prints `Debug foundation smoke succeeded`, the content browser still validates sample content, and the existing smoke scene still prints `Smoke scene loaded`.
11. Check the diff:
git status --short
git diff -- SLICE4.md godot/project.godot godot/scripts/hero godot/scripts/debug godot/scripts/bootstrap godot/scenes/hero godot/scenes/debug godot/scenes/bootstrap tests
Expected result: the diff includes only Slice 4 files and minimal edits to shared debug/root files. User changes outside the slice must not be reverted.
12. Commit this slice after validation:
git add SLICE4.md godot/project.godot godot/scripts/hero godot/scripts/debug godot/scripts/bootstrap godot/scenes/hero godot/scenes/debug godot/scenes/bootstrap tests
git commit -m "Add hero runtime"
If Godot generates `.cs.uid` files for new scripts during validation, include the generated files with the relevant scripts.
## Validation and Acceptance
This slice is accepted when the following observable behaviors are true.
Running tests succeeds:
dotnet test SideScrollerGame.sln
Expected final output should include a passed test summary and no failed tests. The exact number of tests may change, but it should include the existing content validation and debug command tests plus new hero runtime tests.
Building from the repository root succeeds:
dotnet build SideScrollerGame.sln
Expected final lines include:
Build succeeded.
0 Error(s)
Godot can build the C# solution and exit:
.\godot --headless --path godot --build-solutions --quit
The command should exit without a stuck Godot process. If it prints warnings that do not fail the process, record them in this plan.
The hero sandbox can be booted directly and can exercise hero runtime rules headlessly:
.\godot --headless --path godot -- --debug-boot=hero-sandbox --debug-script=hero-smoke --seed=444
Expected output includes:
Debug boot: HeroSandbox
Seed: 444
Hero sandbox smoke loaded
Hero command: DamageHero
Hero command: AddHeroPoints 500
Hero command: GiveSpecialAmmo 3
Hero command: GiveSquadronMate squadron.orbit
Hero command: KillHero
Hero command: RebirthHero
Hero command: SetHeroRetries 0
Hero command: KillHero
Hero state: GameOver
Hero sandbox smoke succeeded
Existing direct boots must still work:
.\godot --headless --path godot -- --debug-boot=debug-sandbox --debug-script=foundation-smoke --seed=333
Expected output includes:
Debug foundation smoke succeeded
And:
.\godot --headless --path godot -- --debug-boot=content-browser --content-validate-only
Expected output includes:
Content validation succeeded
And:
.\godot --headless --path godot -- --debug-boot=smoke --seed=12345
Expected output includes:
Debug boot: Smoke
Seed: 12345
Smoke scene loaded
Opening the editor should show the project and root scene:
.\godot --editor --path godot
In the editor, running with `-- --debug-boot=hero-sandbox` should show the hero sandbox. The hero should move with movement input and remain inside visible bounds. Debug buttons and keyboard shortcuts should update the HUD and overlay immediately. Damage should consume shields, kill should enter dead state, rebirth should consume a retry and restore shields, points should level up the hero, primary slot toggling should change the selected slot, and restart should return the sandbox to its initial hero state.
## Idempotence and Recovery
The implementation is mostly additive and safe to repeat. Creating directories that already exist should do nothing. Re-running tests, builds, and smoke commands should produce the same result.
If `godot/project.godot` already contains one of the planned hero debug input actions, keep the existing action and add only missing actions. Do not duplicate action blocks.
If `.\godot --headless --path godot --build-solutions --quit` generates new `.cs.uid` files for scripts, keep them if Godot generated them during the project scan. Do not hand-edit generated UID values.
If the hero sandbox smoke command hangs, inspect running processes and stop only the Godot process whose command line contains `--debug-boot=hero-sandbox --debug-script=hero-smoke` and this repository path. Record the command and process detail in `Surprises & Discoveries`, then fix the smallest affected script.
If hero movement behaves differently in headless mode than in the editor, keep automated smoke focused on state commands and validate movement bounds manually in the editor. Record the limitation in `Surprises & Discoveries` and `Outcomes & Retrospective`.
If user changes appear in files outside this slice, leave them untouched. If user changes conflict with `godot/project.godot`, `GameRoot.tscn`, or shared debug scripts, read the file and merge only the smallest required change.
Do not use `git restore`, `git checkout --`, reset commands, or equivalent rollback commands to discard local changes. If generated files are unwanted, leave them unstaged unless the user explicitly asks for cleanup.
## Artifacts and Notes
Current successful Slice 3 commands before Slice 4 implementation:
dotnet test SideScrollerGame.sln
Passed! - Failed: 0, Passed: 22, Skipped: 0, Total: 22
.\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
The hero sandbox smoke output after this slice should resemble:
Debug boot: HeroSandbox
Seed: 444
Hero sandbox smoke loaded
Hero command: DamageHero
Hero state: Alive level=1 points=0 shields=2 retries=3
Hero command: AddHeroPoints 500
Hero state: Alive level=2 points=500 shields=3 retries=3
Hero command: TogglePrimaryWeaponSlot
Hero command: GivePrimaryWeapon weapon.primary.basic
Hero command: GiveSpecialAmmo 3
Hero command: GiveSquadronMate squadron.orbit
Hero command: ToggleInvulnerability
Hero command: DamageHero
Hero state: Alive level=2 points=500 shields=3 retries=3
Hero command: KillHero
Hero state: Dead level=2 points=500 shields=0 retries=3
Hero command: RebirthHero
Hero state: Alive level=2 points=500 shields=3 retries=2
Hero command: SetHeroRetries 0
Hero command: KillHero
Hero state: GameOver level=2 points=500 shields=0 retries=0
Hero sandbox smoke succeeded
## Interfaces and Dependencies
The project uses Godot 4.5.1 .NET through `Godot.NET.Sdk/4.5.1` and targets `net8.0` for desktop builds. New Godot-facing scripts must use the `Godot` namespace and partial Godot classes where required by the engine. Pure hero rule types should avoid Godot node dependencies so xUnit tests can run without launching the engine.
At the end of this slice, these public C# types should exist. The exact supporting members may be adjusted during implementation, but the names, responsibilities, and observable behaviors should remain stable.
In `godot/scripts/hero/rules/HeroLifeState.cs`:
namespace SideScrollerGame.Hero.Rules;
public enum HeroLifeState
{
Alive,
Dead,
GameOver
}
In `godot/scripts/hero/rules/HeroRuleConfig.cs`:
namespace SideScrollerGame.Hero.Rules;
public sealed class HeroRuleConfig
{
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; }
public static HeroRuleConfig CreateDefault();
}
In `godot/scripts/hero/rules/HeroRunState.cs`:
namespace SideScrollerGame.Hero.Rules;
public sealed class HeroRunState
{
public string ActiveDifficultyId { get; set; }
public HeroLifeState LifeState { get; set; }
public int Level { get; set; }
public int Points { get; set; }
public int ShieldCharges { get; set; }
public int RetryCount { get; set; }
public IReadOnlyList<string?> PrimaryWeaponSlots { get; }
public int SelectedPrimaryWeaponSlotIndex { get; set; }
public string CurrentSecondaryWeaponId { get; set; }
public string CurrentSpecialWeaponId { get; set; }
public int SpecialAmmo { get; set; }
public string SquadronMateTypeId { get; set; }
public int SquadronMateCount { get; set; }
public string LastStateChange { get; set; }
}
The implementation may keep the primary slots internally mutable while exposing read-only access.
In `godot/scripts/hero/rules/HeroRuntimeService.cs`:
namespace SideScrollerGame.Hero.Rules;
public sealed class HeroRuntimeService
{
public HeroRuntimeService(ContentRegistry registry, HeroRuleConfig config, string difficultyId);
public HeroRunState State { get; }
public HeroRuleResult ApplyHit(bool invulnerable);
public HeroRuleResult Kill();
public HeroRuleResult Rebirth();
public HeroRuleResult AddPoints(int points);
public HeroRuleResult SetLevel(int level);
public HeroRuleResult AddShieldCharge(int amount);
public HeroRuleResult RemoveShieldCharge(int amount);
public HeroRuleResult SetRetryCount(int retryCount);
public HeroRuleResult TogglePrimaryWeaponSlot();
public HeroRuleResult ApplyPrimaryWeaponPickup(string weaponId);
public HeroRuleResult ApplySecondaryWeaponPickup(string weaponId);
public HeroRuleResult AddSpecialAmmo(int amount);
public HeroRuleResult ApplySquadronMatePickup(string squadronMateTypeId);
public HeroRuleResult ClearInventory();
public HeroMissionSnapshot CreateMissionSnapshot();
public event Action<HeroRunState>? StateChanged;
}
The service should validate weapon ids and squadron mate ids against `ContentRegistry` where possible. Invalid ids should return failed `HeroRuleResult` values instead of throwing for normal content mistakes.
In `godot/scripts/hero/HeroActor.cs`:
namespace SideScrollerGame.Hero;
public partial class HeroActor : Node2D
{
public override void _PhysicsProcess(double delta);
public void SetRuntime(HeroRuntimeService runtime);
public void SetPlayBounds(Rect2 bounds);
}
The actor should read movement input, move at an exported speed, and clamp to play bounds.
In `godot/scripts/hero/HeroStateHudController.cs`:
namespace SideScrollerGame.Hero;
public partial class HeroStateHudController : Control
{
public void Bind(HeroRuntimeService runtime);
public void Refresh();
}
In `godot/scripts/debug/HeroSandboxController.cs`:
namespace SideScrollerGame.Debug;
public partial class HeroSandboxController : Node
{
public override void _Ready();
public override void _UnhandledInput(InputEvent @event);
}
The sandbox should use `DebugCommandNode.Execute(...)` for keyboard and button commands. It should not maintain separate command rules.
In `godot/scripts/debug/commands/DebugCommandService.cs`, add scene command handler support:
public void RegisterCommandHandler(DebugCommandId commandId, Func<string?, DebugCommandResult> handler);
public void ClearCommandHandler(DebugCommandId commandId);
Existing debug commands should keep their current behavior. Registered scene handlers should be invoked for command ids the core debug service does not own or for command ids intentionally delegated to the scene.
In `godot/scripts/debug/DebugBootMode.cs`, extend the enum to include:
HeroSandbox
In `godot/scripts/bootstrap/GameRoot.cs`, extend boot loading so:
--debug-boot=hero-sandbox
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: 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.

View File

@@ -1,19 +1,81 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012 # Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Godot", "godot/SideScrollerGame.Godot.csproj", "{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Godot", "godot/SideScrollerGame.Godot.csproj", "{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Content.Tests", "tests\SideScrollerGame.Content.Tests\SideScrollerGame.Content.Tests.csproj", "{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
ExportDebug|Any CPU = ExportDebug|Any CPU Debug|x64 = Debug|x64
ExportRelease|Any CPU = ExportRelease|Any CPU Debug|x86 = Debug|x86
ExportDebug|Any CPU = ExportDebug|Any CPU
ExportDebug|x64 = ExportDebug|x64
ExportDebug|x86 = ExportDebug|x86
ExportRelease|Any CPU = ExportRelease|Any CPU
ExportRelease|x64 = ExportRelease|x64
ExportRelease|x86 = ExportRelease|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|x64.ActiveCfg = Debug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|x64.Build.0 = Debug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|x86.ActiveCfg = Debug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Debug|x86.Build.0 = Debug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|x64.ActiveCfg = ExportDebug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|x64.Build.0 = ExportDebug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|x86.ActiveCfg = ExportDebug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportDebug|x86.Build.0 = ExportDebug|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU {75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|x64.ActiveCfg = ExportRelease|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|x64.Build.0 = ExportRelease|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|x86.ActiveCfg = ExportRelease|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.ExportRelease|x86.Build.0 = ExportRelease|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|Any CPU.Build.0 = Release|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x64.ActiveCfg = Release|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x64.Build.0 = Release|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x86.ActiveCfg = Release|Any CPU
{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}.Release|x86.Build.0 = Release|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|Any CPU.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x64.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x64.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x86.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Debug|x86.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x64.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportDebug|x86.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x64.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.ExportRelease|x86.Build.0 = Debug|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|Any CPU.ActiveCfg = Release|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|Any CPU.Build.0 = Release|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x64.ActiveCfg = Release|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x64.Build.0 = Release|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x86.ActiveCfg = Release|Any CPU
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{4EF48E17-A741-4DD6-BD14-3D2F4DE0BE75} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal

View File

@@ -1,7 +1,7 @@
<Project Sdk="Godot.NET.Sdk/4.5.1"> <Project Sdk="Godot.NET.Sdk/4.5.1">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework> <TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading> <EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View File

@@ -11,9 +11,164 @@ config_version=5
[application] [application]
config/name="SideScrollerGame" config/name="SideScrollerGame"
run/main_scene="res://scenes/bootstrap/GameRoot.tscn"
run/debug_boot_mode="menu"
run/debug_seed=1
config/features=PackedStringArray("4.5", "C#", "Forward Plus") config/features=PackedStringArray("4.5", "C#", "Forward Plus")
config/icon="res://icon.svg" config/icon="res://icon.svg"
[dotnet] [dotnet]
project/assembly_name="SideScrollerGame" project/assembly_name="SideScrollerGame.Godot"
[input]
move_up={
"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":87,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, 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":4194320,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_down={
"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":83,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, 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":4194322,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_left={
"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":65,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, 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":4194319,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
move_right={
"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":68,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
, 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":4194321,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
fire_primary={
"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":32,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
fire_secondary={
"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":70,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
fire_special={
"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":69,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
pause_game={
"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":4194305,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
debug_overlay={
"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":4194336,"physical_keycode":0,"key_label":0,"unicode":0,"location":0,"echo":false,"script":null)
]
}
quick_restart={
"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":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)
]
}
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)
]
}

View File

@@ -0,0 +1,25 @@
[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/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"]
[ext_resource type="PackedScene" path="res://scenes/debug/HeroSandbox.tscn" id="7_hero_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")
HeroSandboxScene = ExtResource("7_hero_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")

View File

@@ -0,0 +1,39 @@
[gd_scene load_steps=2 format=3 uid="uid://cahv34nq56781"]
[ext_resource type="Script" path="res://scripts/debug/ContentBrowserController.cs" id="1_content_browser"]
[node name="ContentBrowser" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_content_browser")
[node name="Title" type="Label" parent="."]
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
offset_left = 16.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = 48.0
grow_horizontal = 2
text = "Content Browser"
[node name="Scroll" type="ScrollContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 16.0
offset_top = 56.0
offset_right = -16.0
offset_bottom = -16.0
grow_horizontal = 2
grow_vertical = 2
[node name="Content" type="Label" parent="Scroll"]
layout_mode = 2
text = "Loading..."

View File

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

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

View File

@@ -0,0 +1,18 @@
[gd_scene load_steps=2 format=3 uid="uid://dpsx0hxc4vd5s"]
[ext_resource type="Script" path="res://scripts/debug/SmokeSceneController.cs" id="1_smoke_scene_controller"]
[node name="SmokeScene" type="Node2D"]
script = ExtResource("1_smoke_scene_controller")
[node name="HeroPlaceholder" type="Polygon2D" parent="."]
position = Vector2(160, 180)
color = Color(0.25, 0.85, 1, 1)
polygon = PackedVector2Array(48, 0, -32, -24, -12, 0, -32, 24)
[node name="SmokeLabel" type="Label" parent="."]
offset_left = 96.0
offset_top = 224.0
offset_right = 384.0
offset_bottom = 256.0
text = "Smoke Scene"

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

View File

@@ -0,0 +1,46 @@
[gd_scene load_steps=2 format=3 uid="uid://cg86dxl2ys1vh"]
[ext_resource type="Script" path="res://scripts/menu/MenuPlaceholder.cs" id="1_menu_placeholder"]
[node name="MenuPlaceholder" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_menu_placeholder")
[node name="Title" type="Label" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -210.0
offset_top = -32.0
offset_right = 210.0
offset_bottom = 0.0
grow_horizontal = 2
grow_vertical = 2
horizontal_alignment = 1
vertical_alignment = 1
text = "SideScrollerGame - Menu Placeholder"
[node name="Hint" type="Label" parent="."]
layout_mode = 1
anchors_preset = 8
anchor_left = 0.5
anchor_top = 0.5
anchor_right = 0.5
anchor_bottom = 0.5
offset_left = -240.0
offset_top = 8.0
offset_right = 240.0
offset_bottom = 40.0
grow_horizontal = 2
grow_vertical = 2
horizontal_alignment = 1
vertical_alignment = 1
text = "Run with -- --debug-boot=smoke for smoke scene"

View File

@@ -0,0 +1,82 @@
#nullable enable
using Godot;
using SideScrollerGame.Debug;
namespace SideScrollerGame.Bootstrap;
public partial class GameRoot : Node
{
public override void _Ready()
{
m_Settings = DebugSettings.Load();
GD.Seed((ulong)m_Settings.Seed);
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);
}
public void LoadBootScene(DebugBootMode bootMode)
{
PackedScene? scene = bootMode switch
{
DebugBootMode.Smoke => SmokeScene,
DebugBootMode.ContentBrowser => ContentBrowserScene,
DebugBootMode.DebugSandbox => DebugSandboxScene,
DebugBootMode.HeroSandbox => HeroSandboxScene,
_ => MenuScene
};
string loadedSceneId = bootMode.ToString();
if (scene is null)
{
GD.PushError($"No scene configured for debug boot mode '{bootMode}'.");
return;
}
if (m_LoadedScene is not null)
{
m_LoadedScene.QueueFree();
}
m_LoadedScene = scene.Instantiate();
AddChild(m_LoadedScene);
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; }
[Export]
public PackedScene? SmokeScene { get; set; }
[Export]
public PackedScene? ContentBrowserScene { get; set; }
[Export]
public PackedScene? DebugSandboxScene { get; set; }
[Export]
public PackedScene? HeroSandboxScene { get; set; }
private Node? m_LoadedScene;
private DebugSettings? m_Settings;
private DebugCommandNode? m_CommandNode;
}

View File

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

View File

@@ -0,0 +1,130 @@
#nullable enable
using System.Collections.Generic;
using System.Linq;
using SideScrollerGame.Content.Definitions;
namespace SideScrollerGame.Content;
public sealed class ContentRegistry
{
public ContentRegistry(IEnumerable<MissionDefinition> missions, IEnumerable<DifficultyDefinition> difficulties, IEnumerable<CameraPathDefinition> cameraPaths, IEnumerable<LevelLayerDefinition> levelLayers, IEnumerable<EnemyTypeDefinition> enemyTypes, IEnumerable<EnemyBehaviorDefinition> enemyBehaviors, IEnumerable<EnemyClusterDefinition> enemyClusters, IEnumerable<CollectibleDefinition> collectibles, IEnumerable<WeaponDefinition> weapons, IEnumerable<SpecialWeaponDefinition> specialWeapons, IEnumerable<SquadronMateTypeDefinition> squadronMateTypes)
{
MissionDefinitions = missions.ToList();
DifficultyDefinitions = difficulties.ToList();
CameraPathDefinitions = cameraPaths.ToList();
LevelLayerDefinitions = levelLayers.ToList();
EnemyTypeDefinitions = enemyTypes.ToList();
EnemyBehaviorDefinitions = enemyBehaviors.ToList();
EnemyClusterDefinitions = enemyClusters.ToList();
CollectibleDefinitions = collectibles.ToList();
WeaponDefinitions = weapons.ToList();
SpecialWeaponDefinitions = specialWeapons.ToList();
SquadronMateTypeDefinitions = squadronMateTypes.ToList();
Missions = ToDictionary(MissionDefinitions);
Difficulties = ToDictionary(DifficultyDefinitions);
CameraPaths = ToDictionary(CameraPathDefinitions);
LevelLayers = ToDictionary(LevelLayerDefinitions);
EnemyTypes = ToDictionary(EnemyTypeDefinitions);
EnemyBehaviors = ToDictionary(EnemyBehaviorDefinitions);
EnemyClusters = ToDictionary(EnemyClusterDefinitions);
Collectibles = ToDictionary(CollectibleDefinitions);
Weapons = ToDictionary(WeaponDefinitions);
SpecialWeapons = ToDictionary(SpecialWeaponDefinitions);
SquadronMateTypes = ToDictionary(SquadronMateTypeDefinitions);
}
public IEnumerable<string> AllDefinitionIds()
{
return AllDefinitions().Select(definition => definition.Id).OrderBy(id => id);
}
public IReadOnlyList<DefinitionEntry> AllDefinitions()
{
return MissionDefinitions.Select(definition => new DefinitionEntry("Mission", definition.Id)).Concat(DifficultyDefinitions.Select(definition => new DefinitionEntry("Difficulty", definition.Id))).Concat(CameraPathDefinitions.Select(definition => new DefinitionEntry("CameraPath", definition.Id))).Concat(LevelLayerDefinitions.Select(definition => new DefinitionEntry("LevelLayer", definition.Id))).Concat(EnemyTypeDefinitions.Select(definition => new DefinitionEntry("EnemyType", definition.Id))).Concat(EnemyBehaviorDefinitions.Select(definition => new DefinitionEntry("EnemyBehavior", definition.Id))).Concat(EnemyClusterDefinitions.Select(definition => new DefinitionEntry("EnemyCluster", definition.Id))).Concat(CollectibleDefinitions.Select(definition => new DefinitionEntry("Collectible", definition.Id))).Concat(WeaponDefinitions.Select(definition => new DefinitionEntry("Weapon", definition.Id))).Concat(SpecialWeaponDefinitions.Select(definition => new DefinitionEntry("SpecialWeapon", definition.Id))).Concat(SquadronMateTypeDefinitions.Select(definition => new DefinitionEntry("SquadronMateType", definition.Id))).OrderBy(definition => definition.Id).ToList();
}
public bool TryGetMission(string id, out MissionDefinition? definition)
{
return Missions.TryGetValue(id, out definition);
}
public bool TryGetDifficulty(string id, out DifficultyDefinition? definition)
{
return Difficulties.TryGetValue(id, out definition);
}
public bool TryGetEnemyType(string id, out EnemyTypeDefinition? definition)
{
return EnemyTypes.TryGetValue(id, out definition);
}
public IReadOnlyList<MissionDefinition> MissionDefinitions { get; }
public IReadOnlyList<DifficultyDefinition> DifficultyDefinitions { get; }
public IReadOnlyList<CameraPathDefinition> CameraPathDefinitions { get; }
public IReadOnlyList<LevelLayerDefinition> LevelLayerDefinitions { get; }
public IReadOnlyList<EnemyTypeDefinition> EnemyTypeDefinitions { get; }
public IReadOnlyList<EnemyBehaviorDefinition> EnemyBehaviorDefinitions { get; }
public IReadOnlyList<EnemyClusterDefinition> EnemyClusterDefinitions { get; }
public IReadOnlyList<CollectibleDefinition> CollectibleDefinitions { get; }
public IReadOnlyList<WeaponDefinition> WeaponDefinitions { get; }
public IReadOnlyList<SpecialWeaponDefinition> SpecialWeaponDefinitions { get; }
public IReadOnlyList<SquadronMateTypeDefinition> SquadronMateTypeDefinitions { get; }
public IReadOnlyDictionary<string, MissionDefinition> Missions { get; }
public IReadOnlyDictionary<string, DifficultyDefinition> Difficulties { get; }
public IReadOnlyDictionary<string, CameraPathDefinition> CameraPaths { get; }
public IReadOnlyDictionary<string, LevelLayerDefinition> LevelLayers { get; }
public IReadOnlyDictionary<string, EnemyTypeDefinition> EnemyTypes { get; }
public IReadOnlyDictionary<string, EnemyBehaviorDefinition> EnemyBehaviors { get; }
public IReadOnlyDictionary<string, EnemyClusterDefinition> EnemyClusters { get; }
public IReadOnlyDictionary<string, CollectibleDefinition> Collectibles { get; }
public IReadOnlyDictionary<string, WeaponDefinition> Weapons { get; }
public IReadOnlyDictionary<string, SpecialWeaponDefinition> SpecialWeapons { get; }
public IReadOnlyDictionary<string, SquadronMateTypeDefinition> SquadronMateTypes { get; }
private static IReadOnlyDictionary<string, TDefinition> ToDictionary<TDefinition>(IEnumerable<TDefinition> definitions) where TDefinition : class
{
return definitions.GroupBy(GetId).Select(group => group.First()).ToDictionary(GetId);
}
private static string GetId<TDefinition>(TDefinition definition)
{
return definition switch
{
MissionDefinition mission => mission.Id,
DifficultyDefinition difficulty => difficulty.Id,
CameraPathDefinition cameraPath => cameraPath.Id,
LevelLayerDefinition levelLayer => levelLayer.Id,
EnemyTypeDefinition enemyType => enemyType.Id,
EnemyBehaviorDefinition enemyBehavior => enemyBehavior.Id,
EnemyClusterDefinition enemyCluster => enemyCluster.Id,
CollectibleDefinition collectible => collectible.Id,
WeaponDefinition weapon => weapon.Id,
SpecialWeaponDefinition specialWeapon => specialWeapon.Id,
SquadronMateTypeDefinition squadronMateDefinition => squadronMateDefinition.Id,
_ => string.Empty
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace SideScrollerGame.Content.Definitions;
public sealed record CameraPathDefinition(string Id, string DisplayName, IReadOnlyList<CameraPathPointDefinition> Points, double DefaultSpeed);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
#nullable enable
namespace SideScrollerGame.Content.Definitions;
public sealed record CollectibleDefinition(string Id, string DisplayName, CollectibleKind Kind, int Value, string? ReferencedContentId = null);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record DifficultyModifierSet(double EnemyHealthMultiplier, double EnemyProjectileSpeedMultiplier, double EnemyFireCadenceMultiplier, double EnemySpawnDensityMultiplier, double ClusterSpawnIntervalMultiplier, double BossHealthMultiplier, double BossPhaseTimingMultiplier, double CollectibleDropRateMultiplier, double SpecialWeaponInitialAmmoMultiplier, double ScoreMultiplier);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace SideScrollerGame.Content.Definitions;
public sealed record EnemyClusterDefinition(string Id, string DisplayName, IReadOnlyList<SpawnScheduleEntryDefinition> Spawns, int CompletionRewardPoints, ClusterEscapeRule EscapeRule);

View File

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

View File

@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace SideScrollerGame.Content.Definitions;
public sealed record EnemyTypeDefinition(string Id, string DisplayName, int Health, int ScoreValue, IReadOnlyList<string> BehaviorIds, IReadOnlyList<string> DropCollectibleIds);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
using System.Collections.Generic;
namespace SideScrollerGame.Content.Definitions;
public sealed record MissionDefinition(string Id, string DisplayName, string DefaultDifficultyId, string CameraPathId, IReadOnlyList<string> BackgroundLayerIds, IReadOnlyList<string> ForegroundLayerIds, IReadOnlyList<string> ClusterIds, IReadOnlyList<string> CollectibleIds, IReadOnlyList<string> SpecialWeaponIds, IReadOnlyList<string> TimelineMarkers);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Content.Definitions;
public sealed record WeaponDefinition(string Id, string DisplayName, WeaponKind Kind, int Damage, double FireCadenceSeconds, double ProjectileSpeed, int ProjectileCount, bool ConsumesEnemyProjectiles);

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
using System.Collections.Generic;
using SideScrollerGame.Content.Definitions;
namespace SideScrollerGame.Content.Samples;
public static class SampleContent
{
public static ContentRegistry CreateRegistry()
{
IReadOnlyList<DifficultyDefinition> difficulties =
[
CreateDifficulty("difficulty.easy", "Easy", 0.8, 4, 4, 0.75),
CreateDifficulty("difficulty.normal", "Normal", 1.0, 3, 3, 1.0),
CreateDifficulty("difficulty.hard", "Hard", 1.35, 2, 2, 1.5)
];
IReadOnlyList<CameraPathDefinition> cameraPaths =
[
new("camera.test.path", "Test Camera Path", [
new CameraPathPointDefinition(0.0, 0.0, 0.0, 120.0),
new CameraPathPointDefinition(10.0, 1200.0, 0.0, 180.0),
new CameraPathPointDefinition(20.0, 2400.0, 96.0, 90.0)
], 120.0)
];
IReadOnlyList<LevelLayerDefinition> levelLayers =
[
new("layer.background.stars", "Repeating Background", LayerKind.Background, 0.35, true),
new("layer.foreground.clouds", "Repeating Foreground", LayerKind.Foreground, 1.4, true)
];
IReadOnlyList<EnemyBehaviorDefinition> enemyBehaviors =
[
new("behavior.enemy.serial", "Serial Movement", [
new BehaviorTrackDefinition("track.main", BehaviorTrackMode.Serial, [
new BehaviorEventDefinition(BehaviorEventKind.MovePath, 0.0, 2.0),
new BehaviorEventDefinition(BehaviorEventKind.Wait, 2.0, 0.5),
new BehaviorEventDefinition(BehaviorEventKind.FireProjectile, 2.5, 0.1, "weapon.enemy.placeholder")
])
]),
new("behavior.enemy.parallel", "Parallel Movement And Fire", [
new BehaviorTrackDefinition("track.move", BehaviorTrackMode.Parallel, [
new BehaviorEventDefinition(BehaviorEventKind.MovePath, 0.0, 5.0)
]),
new BehaviorTrackDefinition("track.fire", BehaviorTrackMode.Parallel, [
new BehaviorEventDefinition(BehaviorEventKind.FireProjectile, 0.5, 4.0, "weapon.enemy.placeholder")
])
])
];
IReadOnlyList<CollectibleDefinition> collectibles =
[
new("collectible.points.small", "Small Points", CollectibleKind.Points, 100),
new("collectible.squadron.orbit", "Orbit Squadron Mate", CollectibleKind.SquadronMate, 1, "squadron.orbit")
];
IReadOnlyList<EnemyTypeDefinition> enemyTypes =
[
new("enemy.serial", "Serial Enemy", 12, 150, ["behavior.enemy.serial"], ["collectible.points.small"]),
new("enemy.parallel", "Parallel Enemy", 18, 250, ["behavior.enemy.parallel"], ["collectible.squadron.orbit"])
];
IReadOnlyList<EnemyClusterDefinition> enemyClusters =
[
new("cluster.opening", "Opening Cluster", [
new SpawnScheduleEntryDefinition("enemy.serial", 1.0, 1.1, 0.25),
new SpawnScheduleEntryDefinition("enemy.parallel", 3.0, 1.15, 0.7)
], 500, ClusterEscapeRule.PreventReward)
];
IReadOnlyList<WeaponDefinition> weapons =
[
new("weapon.primary.basic", "Basic Primary", WeaponKind.PrimaryBallistic, 3, 0.18, 640.0, 1, false),
new("weapon.secondary.vertical", "Vertical Secondary", WeaponKind.SecondaryVertical, 2, 0.35, 500.0, 1, false)
];
IReadOnlyList<SpecialWeaponDefinition> specialWeapons =
[
new("weapon.special.bomb", "Bomb Special", SpecialWeaponKind.Bomb, 12, 3, 30)
];
IReadOnlyList<SquadronMateTypeDefinition> squadronMateTypes =
[
new("squadron.orbit", "Orbit Mate", SquadronMateFormationKind.Orbit, 48.0, 10.0)
];
IReadOnlyList<MissionDefinition> missions =
[
new("mission.test", "Test Mission", "difficulty.normal", "camera.test.path", ["layer.background.stars"], ["layer.foreground.clouds"], ["cluster.opening"], ["collectible.points.small", "collectible.squadron.orbit"], ["weapon.special.bomb"], ["intro", "cluster.opening", "boss.intro", "outro"])
];
return new ContentRegistry(missions, difficulties, cameraPaths, levelLayers, enemyTypes, enemyBehaviors, enemyClusters, collectibles, weapons, specialWeapons, squadronMateTypes);
}
private static DifficultyDefinition CreateDifficulty(string id, string displayName, double challengeMultiplier, int shields, int retries, double scoreMultiplier)
{
return new DifficultyDefinition(id, displayName, new DifficultyModifierSet(challengeMultiplier, challengeMultiplier, challengeMultiplier, challengeMultiplier, 1.0 / challengeMultiplier, challengeMultiplier, 1.0 / challengeMultiplier, 1.0, 1.0, scoreMultiplier), shields, retries);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
using System.Linq;
namespace SideScrollerGame.Content.Validation;
public sealed class ContentValidationResult
{
public ContentValidationResult(IEnumerable<ContentValidationMessage> messages)
{
Messages = messages.ToList();
}
public IReadOnlyList<ContentValidationMessage> Messages { get; }
public bool HasErrors => Messages.Any(message => message.Severity == ContentValidationSeverity.Error);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,374 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using SideScrollerGame.Content.Definitions;
namespace SideScrollerGame.Content.Validation;
public sealed class ContentValidator
{
public ContentValidationResult Validate(ContentRegistry registry)
{
List<ContentValidationMessage> messages = new();
ValidateDuplicateIds(registry, messages);
ValidateDifficulties(registry, messages);
ValidateCameraPaths(registry, messages);
ValidateLayers(registry, messages);
ValidateBehaviors(registry, messages);
ValidateEnemies(registry, messages);
ValidateClusters(registry, messages);
ValidateCollectibles(registry, messages);
ValidateWeapons(registry, messages);
ValidateSpecialWeapons(registry, messages);
ValidateSquadronMateTypes(registry, messages);
ValidateMissions(registry, messages);
return new ContentValidationResult(messages);
}
private static void ValidateDuplicateIds(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (IGrouping<string, DefinitionEntry> group in registry.AllDefinitions().GroupBy(definition => definition.Id))
{
if (string.IsNullOrWhiteSpace(group.Key))
{
AddError(messages, "content.id.empty", "A definition has an empty id.");
}
else if (group.Count() > 1)
{
string kinds = string.Join(", ", group.Select(definition => definition.Kind));
AddError(messages, "content.id.duplicate", $"Duplicate definition id '{group.Key}' appears in {kinds}.", group.Key);
}
}
}
private static void ValidateDifficulties(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (DifficultyDefinition difficulty in registry.DifficultyDefinitions)
{
if (difficulty.HeroStartingShieldCharges < 0)
{
AddError(messages, "difficulty.shields.invalid", $"Difficulty '{difficulty.Id}' has negative starting shields.", difficulty.Id);
}
if (difficulty.HeroRetryCount < 0)
{
AddError(messages, "difficulty.retries.invalid", $"Difficulty '{difficulty.Id}' has negative retry count.", difficulty.Id);
}
ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyHealthMultiplier", difficulty.Modifiers.EnemyHealthMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyProjectileSpeedMultiplier", difficulty.Modifiers.EnemyProjectileSpeedMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "EnemyFireCadenceMultiplier", difficulty.Modifiers.EnemyFireCadenceMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "EnemySpawnDensityMultiplier", difficulty.Modifiers.EnemySpawnDensityMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "ClusterSpawnIntervalMultiplier", difficulty.Modifiers.ClusterSpawnIntervalMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "BossHealthMultiplier", difficulty.Modifiers.BossHealthMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "BossPhaseTimingMultiplier", difficulty.Modifiers.BossPhaseTimingMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "CollectibleDropRateMultiplier", difficulty.Modifiers.CollectibleDropRateMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "SpecialWeaponInitialAmmoMultiplier", difficulty.Modifiers.SpecialWeaponInitialAmmoMultiplier);
ValidatePositiveMultiplier(messages, difficulty.Id, "ScoreMultiplier", difficulty.Modifiers.ScoreMultiplier);
}
}
private static void ValidateCameraPaths(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (CameraPathDefinition cameraPath in registry.CameraPathDefinitions)
{
if (cameraPath.Points.Count == 0)
{
AddError(messages, "camera.points.empty", $"Camera path '{cameraPath.Id}' has no points.", cameraPath.Id);
}
if (cameraPath.DefaultSpeed <= 0.0)
{
AddError(messages, "camera.speed.invalid", $"Camera path '{cameraPath.Id}' has non-positive default speed.", cameraPath.Id);
}
foreach (CameraPathPointDefinition point in cameraPath.Points)
{
if (point.TimeSeconds < 0.0)
{
AddError(messages, "camera.point.time.invalid", $"Camera path '{cameraPath.Id}' has a point before mission start.", cameraPath.Id);
}
if (point.Speed <= 0.0)
{
AddError(messages, "camera.point.speed.invalid", $"Camera path '{cameraPath.Id}' has a point with non-positive speed.", cameraPath.Id);
}
}
}
}
private static void ValidateLayers(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (LevelLayerDefinition layer in registry.LevelLayerDefinitions)
{
if (layer.ScrollFactor < 0.0)
{
AddError(messages, "layer.scroll.invalid", $"Layer '{layer.Id}' has negative scroll factor.", layer.Id);
}
}
}
private static void ValidateBehaviors(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (EnemyBehaviorDefinition behavior in registry.EnemyBehaviorDefinitions)
{
if (behavior.Tracks.Count == 0)
{
AddError(messages, "behavior.tracks.empty", $"Behavior '{behavior.Id}' has no tracks.", behavior.Id);
}
foreach (BehaviorTrackDefinition track in behavior.Tracks)
{
if (track.Events.Count == 0)
{
AddError(messages, "behavior.track.events.empty", $"Behavior '{behavior.Id}' track '{track.Id}' has no events.", behavior.Id);
}
foreach (BehaviorEventDefinition behaviorEvent in track.Events)
{
if (behaviorEvent.StartSeconds < 0.0)
{
AddError(messages, "behavior.event.start.invalid", $"Behavior '{behavior.Id}' has an event before mission start.", behavior.Id);
}
if (behaviorEvent.DurationSeconds < 0.0)
{
AddError(messages, "behavior.event.duration.invalid", $"Behavior '{behavior.Id}' has an event with negative duration.", behavior.Id);
}
}
}
}
}
private static void ValidateEnemies(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (EnemyTypeDefinition enemyType in registry.EnemyTypeDefinitions)
{
if (enemyType.Health <= 0)
{
AddError(messages, "enemy.health.invalid", $"Enemy '{enemyType.Id}' has non-positive health.", enemyType.Id);
}
if (enemyType.ScoreValue < 0)
{
AddError(messages, "enemy.score.invalid", $"Enemy '{enemyType.Id}' has negative score value.", enemyType.Id);
}
if (enemyType.BehaviorIds.Count == 0)
{
AddError(messages, "enemy.behaviors.empty", $"Enemy '{enemyType.Id}' has no behavior references.", enemyType.Id);
}
foreach (string behaviorId in enemyType.BehaviorIds)
{
if (!registry.EnemyBehaviors.ContainsKey(behaviorId))
{
AddError(messages, "enemy.behavior.missing", $"Enemy '{enemyType.Id}' references missing behavior '{behaviorId}'.", enemyType.Id);
}
}
foreach (string collectibleId in enemyType.DropCollectibleIds)
{
if (!registry.Collectibles.ContainsKey(collectibleId))
{
AddError(messages, "enemy.collectible.missing", $"Enemy '{enemyType.Id}' references missing drop collectible '{collectibleId}'.", enemyType.Id);
}
}
}
}
private static void ValidateClusters(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (EnemyClusterDefinition cluster in registry.EnemyClusterDefinitions)
{
if (cluster.Spawns.Count == 0)
{
AddError(messages, "cluster.spawns.empty", $"Cluster '{cluster.Id}' has no spawns.", cluster.Id);
}
if (cluster.CompletionRewardPoints < 0)
{
AddError(messages, "cluster.reward.invalid", $"Cluster '{cluster.Id}' has negative completion reward.", cluster.Id);
}
foreach (SpawnScheduleEntryDefinition spawn in cluster.Spawns)
{
if (spawn.SpawnTimeSeconds < 0.0)
{
AddError(messages, "cluster.spawn.time.invalid", $"Cluster '{cluster.Id}' has a spawn before mission start.", cluster.Id);
}
if (!registry.EnemyTypes.ContainsKey(spawn.EnemyTypeId))
{
AddError(messages, "cluster.enemy.missing", $"Cluster '{cluster.Id}' references missing enemy type '{spawn.EnemyTypeId}'.", cluster.Id);
}
}
}
}
private static void ValidateCollectibles(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (CollectibleDefinition collectible in registry.CollectibleDefinitions)
{
if (collectible.Value < 0)
{
AddError(messages, "collectible.value.invalid", $"Collectible '{collectible.Id}' has negative value.", collectible.Id);
}
if (collectible.Kind == CollectibleKind.PrimaryWeapon && !ReferencedContentExists(registry.Weapons, collectible.ReferencedContentId))
{
AddError(messages, "collectible.weapon.missing", $"Collectible '{collectible.Id}' references missing primary weapon '{collectible.ReferencedContentId}'.", collectible.Id);
}
if (collectible.Kind == CollectibleKind.SecondaryWeapon && !ReferencedContentExists(registry.Weapons, collectible.ReferencedContentId))
{
AddError(messages, "collectible.weapon.missing", $"Collectible '{collectible.Id}' references missing secondary weapon '{collectible.ReferencedContentId}'.", collectible.Id);
}
if (collectible.Kind == CollectibleKind.SpecialAmmo && !ReferencedContentExists(registry.SpecialWeapons, collectible.ReferencedContentId))
{
AddError(messages, "collectible.special.missing", $"Collectible '{collectible.Id}' references missing special weapon '{collectible.ReferencedContentId}'.", collectible.Id);
}
if (collectible.Kind == CollectibleKind.SquadronMate && !ReferencedContentExists(registry.SquadronMateTypes, collectible.ReferencedContentId))
{
AddError(messages, "collectible.squadron.missing", $"Collectible '{collectible.Id}' references missing squadron mate type '{collectible.ReferencedContentId}'.", collectible.Id);
}
}
}
private static void ValidateWeapons(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (WeaponDefinition weapon in registry.WeaponDefinitions)
{
if (weapon.Damage <= 0)
{
AddError(messages, "weapon.damage.invalid", $"Weapon '{weapon.Id}' has non-positive damage.", weapon.Id);
}
if (weapon.FireCadenceSeconds <= 0.0)
{
AddError(messages, "weapon.cadence.invalid", $"Weapon '{weapon.Id}' has non-positive fire cadence.", weapon.Id);
}
if (weapon.ProjectileSpeed <= 0.0)
{
AddError(messages, "weapon.speed.invalid", $"Weapon '{weapon.Id}' has non-positive projectile speed.", weapon.Id);
}
if (weapon.ProjectileCount <= 0)
{
AddError(messages, "weapon.projectiles.invalid", $"Weapon '{weapon.Id}' has non-positive projectile count.", weapon.Id);
}
}
}
private static void ValidateSpecialWeapons(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (SpecialWeaponDefinition specialWeapon in registry.SpecialWeaponDefinitions)
{
if (specialWeapon.InitialAmmo < 0)
{
AddError(messages, "special.ammo.invalid", $"Special weapon '{specialWeapon.Id}' has negative initial ammo.", specialWeapon.Id);
}
if (specialWeapon.AmmoPickupAmount < 0)
{
AddError(messages, "special.pickup.invalid", $"Special weapon '{specialWeapon.Id}' has negative pickup ammo.", specialWeapon.Id);
}
if (specialWeapon.Damage <= 0)
{
AddError(messages, "special.damage.invalid", $"Special weapon '{specialWeapon.Id}' has non-positive damage.", specialWeapon.Id);
}
}
}
private static void ValidateSquadronMateTypes(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (SquadronMateTypeDefinition squadronMateType in registry.SquadronMateTypeDefinitions)
{
if (squadronMateType.Spacing < 0.0)
{
AddError(messages, "squadron.spacing.invalid", $"Squadron mate type '{squadronMateType.Id}' has negative spacing.", squadronMateType.Id);
}
if (squadronMateType.MovementSmoothing < 0.0)
{
AddError(messages, "squadron.smoothing.invalid", $"Squadron mate type '{squadronMateType.Id}' has negative movement smoothing.", squadronMateType.Id);
}
}
}
private static void ValidateMissions(ContentRegistry registry, List<ContentValidationMessage> messages)
{
foreach (MissionDefinition mission in registry.MissionDefinitions)
{
RequireReference(messages, mission.Id, mission.DefaultDifficultyId, registry.Difficulties, "mission.difficulty.missing", "default difficulty");
RequireReference(messages, mission.Id, mission.CameraPathId, registry.CameraPaths, "mission.camera.missing", "camera path");
RequireReferences(messages, mission.Id, mission.BackgroundLayerIds, registry.LevelLayers, "mission.background.missing", "background layer");
RequireReferences(messages, mission.Id, mission.ForegroundLayerIds, registry.LevelLayers, "mission.foreground.missing", "foreground layer");
RequireReferences(messages, mission.Id, mission.ClusterIds, registry.EnemyClusters, "mission.cluster.missing", "cluster");
RequireReferences(messages, mission.Id, mission.CollectibleIds, registry.Collectibles, "mission.collectible.missing", "collectible");
RequireReferences(messages, mission.Id, mission.SpecialWeaponIds, registry.SpecialWeapons, "mission.special.missing", "special weapon");
if (mission.BackgroundLayerIds.Count == 0)
{
AddError(messages, "mission.background.empty", $"Mission '{mission.Id}' has no background layers.", mission.Id);
}
if (mission.ForegroundLayerIds.Count == 0)
{
AddError(messages, "mission.foreground.empty", $"Mission '{mission.Id}' has no foreground layers.", mission.Id);
}
if (mission.ClusterIds.Count == 0)
{
AddError(messages, "mission.clusters.empty", $"Mission '{mission.Id}' has no clusters.", mission.Id);
}
if (mission.TimelineMarkers.Count == 0)
{
AddError(messages, "mission.markers.empty", $"Mission '{mission.Id}' has no timeline markers.", mission.Id);
}
}
}
private static void ValidatePositiveMultiplier(List<ContentValidationMessage> messages, string definitionId, string fieldName, double value)
{
if (value <= 0.0 || double.IsNaN(value) || double.IsInfinity(value))
{
AddError(messages, "difficulty.multiplier.invalid", $"Difficulty '{definitionId}' has non-positive {fieldName}.", definitionId);
}
}
private static bool ReferencedContentExists<TDefinition>(IReadOnlyDictionary<string, TDefinition> definitions, string? id)
{
return !string.IsNullOrWhiteSpace(id) && definitions.ContainsKey(id);
}
private static void RequireReferences<TDefinition>(List<ContentValidationMessage> messages, string ownerId, IEnumerable<string> referenceIds, IReadOnlyDictionary<string, TDefinition> definitions, string code, string referenceKind)
{
foreach (string referenceId in referenceIds)
{
RequireReference(messages, ownerId, referenceId, definitions, code, referenceKind);
}
}
private static void RequireReference<TDefinition>(List<ContentValidationMessage> messages, string ownerId, string referenceId, IReadOnlyDictionary<string, TDefinition> definitions, string code, string referenceKind)
{
if (!definitions.ContainsKey(referenceId))
{
AddError(messages, code, $"Definition '{ownerId}' references missing {referenceKind} '{referenceId}'.", ownerId);
}
}
private static void AddError(List<ContentValidationMessage> messages, string code, string message, string? definitionId = null)
{
messages.Add(new ContentValidationMessage(ContentValidationSeverity.Error, code, message, definitionId));
}
}

View File

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

View File

@@ -0,0 +1,80 @@
#nullable enable
using System;
using System.Linq;
using System.Text;
using Godot;
using SideScrollerGame.Content;
using SideScrollerGame.Content.Samples;
using SideScrollerGame.Content.Validation;
namespace SideScrollerGame.Debug;
public partial class ContentBrowserController : Control
{
public override void _Ready()
{
ContentRegistry registry = SampleContent.CreateRegistry();
ContentValidationResult validation = new ContentValidator().Validate(registry);
string report = BuildReport(registry, validation);
SetText(report);
if (!ShouldValidateAndQuit())
{
return;
}
GD.Print(report);
GD.Print(validation.HasErrors ? "Content validation failed" : "Content validation succeeded");
GetTree().Quit(validation.HasErrors ? 1 : 0);
}
private static string BuildReport(ContentRegistry registry, ContentValidationResult validation)
{
StringBuilder builder = new();
builder.AppendLine("Loaded content definitions:");
foreach (string id in registry.AllDefinitionIds())
{
builder.AppendLine(id);
}
if (validation.Messages.Count > 0)
{
builder.AppendLine();
builder.AppendLine("Validation messages:");
foreach (ContentValidationMessage message in validation.Messages)
{
builder.AppendLine($"{message.Severity}: {message.Code}: {message.Message}");
}
}
return builder.ToString().TrimEnd();
}
private static bool ShouldValidateAndQuit()
{
return IsHeadless() && OS.GetCmdlineUserArgs().Any(argument => argument.Equals(ValidateOnlyArgument, StringComparison.OrdinalIgnoreCase));
}
private static bool IsHeadless()
{
return DisplayServer.GetName().Equals("headless", StringComparison.OrdinalIgnoreCase);
}
private void SetText(string report)
{
Label? title = GetNodeOrNull<Label>("Title");
if (title is not null)
{
title.Text = "Content Browser";
}
Label? content = GetNodeOrNull<Label>("Scroll/Content");
if (content is not null)
{
content.Text = report;
}
}
private const string ValidateOnlyArgument = "--content-validate-only";
}

View File

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

View File

@@ -0,0 +1,10 @@
namespace SideScrollerGame.Debug;
public enum DebugBootMode
{
Menu,
Smoke,
ContentBrowser,
DebugSandbox,
HeroSandbox
}

Some files were not shown because too many files have changed in this diff Show More