Compare commits

9 Commits

Author SHA1 Message Date
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
55 changed files with 6613 additions and 22 deletions

8
.gitignore vendored
View File

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

View File

@@ -57,7 +57,7 @@ Add regression tests for every gameplay bug fix when practical. If scene-level a
- 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.
- 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 file you touched.
- 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
- The documentation should always represent the current state in its entirety and not derail into a historical development log.

View File

@@ -1,19 +1,107 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 2012
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Godot", "godot/SideScrollerGame.Godot.csproj", "{75DE3F78-FF5C-4E58-8315-8AEF5BF95BBA}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Sim", "src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj", "{FCF8F1C2-B854-4F88-8106-65102689E5E3}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SideScrollerGame.Sim.Tests", "tests\SideScrollerGame.Sim.Tests\SideScrollerGame.Sim.Tests.csproj", "{6F63FE1D-9388-4826-9781-63D8C0AE4155}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
ExportDebug|Any CPU = ExportDebug|Any CPU
ExportRelease|Any CPU = ExportRelease|Any CPU
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
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
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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|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.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.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
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x64.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x64.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x86.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Debug|x86.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x64.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportDebug|x86.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x64.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.ExportRelease|x86.Build.0 = Debug|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|Any CPU.Build.0 = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x64.ActiveCfg = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x64.Build.0 = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x86.ActiveCfg = Release|Any CPU
{6F63FE1D-9388-4826-9781-63D8C0AE4155}.Release|x86.Build.0 = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x64.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x64.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x86.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Debug|x86.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|Any CPU.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|Any CPU.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x64.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x64.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x86.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportDebug|x86.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|Any CPU.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|Any CPU.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x64.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x64.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x86.ActiveCfg = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.ExportRelease|x86.Build.0 = Debug|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|Any CPU.Build.0 = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x64.ActiveCfg = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x64.Build.0 = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x86.ActiveCfg = Release|Any CPU
{FCF8F1C2-B854-4F88-8106-65102689E5E3}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{6F63FE1D-9388-4826-9781-63D8C0AE4155} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal

View File

@@ -1,7 +1,10 @@
<Project Sdk="Godot.NET.Sdk/4.5.1">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<TargetFramework Condition=" '$(GodotTargetPlatform)' == 'android' ">net9.0</TargetFramework>
<EnableDynamicLoading>true</EnableDynamicLoading>
</PropertyGroup>
</Project>

View File

@@ -14,10 +14,13 @@ The user-visible outcome is not merely “new projects were added.” The outcom
- [x] (2026-04-16 08:24Z) Reviewed `PLANS.md`, the original `groundwork.md`, `SideScrollerGame.sln`, and `godot/SideScrollerGame.Godot.csproj` to anchor this ExecPlan to the current repository state.
- [x] (2026-04-16 08:32Z) Rewrote `groundwork.md` as a self-contained ExecPlan with milestones, repository orientation, exact commands, validation guidance, and living-document bookkeeping.
- [ ] Create `src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj` and `tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj`, add them to `SideScrollerGame.sln`, and wire both projects to the simulation assembly.
- [ ] Copy the current root `FixPoint/*.cs` files into `src/SideScrollerGame.Sim/FixPoint/`, update namespaces, and make the simulation project the only assembly that compiles deterministic math code.
- [ ] Implement the first runnable simulation spine: `Simulation`, `SimulationState`, `WorldSnapshot`, `TickActionBatch`, deterministic random number generation, and per-tick hashes.
- [ ] Implement versioned save/load, replay recording, replay playback, and the optional round-trip verification modes.
- [x] (2026-04-16 08:49Z) Created `src/SideScrollerGame.Sim/SideScrollerGame.Sim.csproj` and `tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj`, added both to `SideScrollerGame.sln`, and wired the Godot and test projects to reference the simulation assembly.
- [x] (2026-04-16 08:53Z) Copied the root `FixPoint/*.cs` files into `src/SideScrollerGame.Sim/FixPoint/`, updated the namespace to `SideScrollerGame.Sim.Math`, and added a smoke test proving the simulation project owns the deterministic math layer.
- [x] (2026-04-16 09:11Z) Added bootstrap compatibility math types missing from the copied `FixPoint` subset and validated the scaffold with `dotnet build`, `dotnet test`, and `.\godot.cmd --headless --quit --path .\godot --build-solutions`.
- [x] (2026-04-16 09:46Z) Implemented the first runnable simulation spine with `Simulation`, `SimulationState`, `WorldSnapshot`, `TickActionBatch`, deterministic fixed-tick movement, deterministic random advancement, and per-tick state hashes.
- [x] (2026-04-16 09:46Z) Implemented versioned save/load, replay recording, replay playback, and the `None`, `RoundTripState`, and `RoundTripAndStepClone` verification modes with deterministic unit coverage.
- [x] (2026-04-16 10:34Z) Added the first data-driven level runtime slice: world bounds clamping, hazard damage resolution, one-shot trigger activation, and serialization coverage for health plus activated triggers.
- [x] (2026-04-16 11:26Z) Added the first platformer locomotion slice: authored solid platforms, configurable platformer motion, grounded state, gravity, jump buffering, coyote-time jumps, and deterministic save/load coverage for the new runtime fields.
- [ ] Implement deterministic movement, collision, damage resolution, triggers, and the fixed tick pipeline with exhaustive simulation tests.
- [ ] Implement data-driven definitions for heroes, enemies, weapons, projectiles, pickups, modifiers, squads, encounters, and level runtime data.
- [ ] Implement Godot host adapters for input translation, fixed-step execution, interpolation, presentation mapping, sound playback, music transitions, and debug transport controls.
@@ -32,6 +35,18 @@ The user-visible outcome is not merely “new projects were added.” The outcom
Evidence: `rg --files` lists `FixPoint/*.cs`, `godot/SideScrollerGame.Godot.csproj`, `SideScrollerGame.sln`, `PLANS.md`, and `groundwork.md`, but no `src/` or `tests/` projects.
- Observation: the repository root contains `godot.cmd`, so all Godot commands in this plan should call that wrapper with `--path .\godot`.
Evidence: `rg --files` lists both `godot.cmd` and `godot/project.godot`.
- Observation: copied `FixPoint` sources inherit the root files' read-only attribute, so the migration step must clear that attribute before applying namespace edits.
Evidence: `Get-Item src/SideScrollerGame.Sim/FixPoint/FixPoint16.cs` reported `Attributes : ReadOnly, Archive` immediately after the copy.
- Observation: the root `FixPoint` subset is not self-contained; it references compatibility types such as `IntVector2`, `SInt32Vector2`, `SFixPointQuaternion`, `SFixPointQuaternionTransform`, and `IntMath` that are not present in this repository snapshot.
Evidence: the first `dotnet build SideScrollerGame.sln` attempt after the copy failed with CS0246 and CS0103 errors pointing at those missing identifiers inside `FixPointVector2.cs`, `FixPointVector3.cs`, `FixPoint16.cs`, and `FixPoint16Long.cs`.
- Observation: `.\godot.cmd --headless --path .\godot --build-solutions` does not terminate on its own in this checkout, but adding `--quit` produces the expected build and exit behavior.
Evidence: the plain command timed out after five minutes, while `.\godot.cmd --headless --quit --path .\godot --build-solutions` completed successfully in roughly three seconds.
- Observation: the replay and state persistence layer can stay engine-agnostic by serializing fixed-point raw values and action documents through `System.Text.Json`; no Godot serialization hooks were needed for the initial deterministic spine.
Evidence: the simulation tests replay hashes successfully after round-tripping `ReplayRecordSerializer` and `Simulation.SaveState` payloads.
- Observation: the first meaningful gameplay slice can stay fully data-driven by modeling the level as world bounds plus axis-aligned hazards and triggers, while still preserving deterministic hashes and save/load behavior.
Evidence: the latest simulation tests clamp movement to authored bounds, apply hazard damage, and persist activated trigger ids through `Simulation.SaveState`.
- Observation: preserving the earlier free-move controls while introducing platformer locomotion is easier if platformer motion is explicit per player definition rather than implied for every actor immediately.
Evidence: the existing deterministic movement tests still exercise direct axis motion, while the new platformer tests author `UsesPlatformerMotion`, gravity, jump velocity, and support geometry without regressing the earlier coverage.
## Decision Log
@@ -47,12 +62,24 @@ The user-visible outcome is not merely “new projects were added.” The outcom
- Decision: use `godot.cmd` in all concrete steps instead of the shorthand `.\godot`.
Rationale: `godot.cmd` is the wrapper that actually exists in this checkout, so the plan must describe commands that a novice can run without guessing aliases.
Date/Author: 2026-04-16 / Codex
- Decision: keep the migration moving by introducing local compatibility shims for the missing fixed-point support types instead of blocking Milestone 1 on a broader math-library archaeology pass.
Rationale: the copied `FixPoint` subset already compiles and is enough for the upcoming deterministic simulation spine, while the shim types can be replaced later if fuller upstream math primitives become necessary.
Date/Author: 2026-04-16 / Codex
- Decision: use deterministic JSON documents for the first state and replay formats instead of building a custom binary serializer immediately.
Rationale: explicit versioned JSON documents are easy to diff, easy to round-trip in tests, and good enough for proving deterministic save/load behavior before optimization work begins.
Date/Author: 2026-04-16 / Codex
- Decision: start Milestone 4 with axis-aligned world bounds, hazards, and one-shot triggers before tackling richer collision geometry or combat systems.
Rationale: this delivers the first data-driven fixed-step gameplay rules with low implementation risk and keeps the deterministic test surface small enough to maintain 100 percent coverage.
Date/Author: 2026-04-16 / Codex
- Decision: introduce platformer locomotion as an authored capability on `PlayerDefinition` instead of replacing the existing free-move controls in one commit.
Rationale: that keeps earlier deterministic tests stable, lets the simulation support both bootstrap movement styles during the transition, and provides a controlled seam for later host integration.
Date/Author: 2026-04-16 / Codex
## Outcomes & Retrospective
At the moment, the outcome is a corrected planning artifact rather than finished gameplay infrastructure. The original groundwork note has been converted into a proper ExecPlan that a future contributor can execute step by step. No simulation code, tests, or host adapters have been implemented yet, so the gap between purpose and delivered software remains the actual implementation work described below.
The repository now has a real deterministic simulation foundation instead of only a planning note. The pure `SideScrollerGame.Sim` project owns the fixed-point math layer, deterministic stepping, save/load, replay, verification, world bounds, hazards, triggers, authored solid platforms, and the first configurable platformer locomotion rules. Fast .NET tests now prove state hashing, replay fidelity, state persistence, grounded transitions, coyote-time jumps, and jump buffering without starting Godot.
The main lesson from this rewrite is that the repository already contains enough concrete structure to support a precise plan. The plan therefore names real files, current commands, and migration-safe steps instead of speaking in abstract architecture terms.
The main remaining gap is breadth, not existence. Richer collision, combat, enemies, authored content compilation, and the Godot host adapters are still unfinished, but the current code already demonstrates the intended architecture: gameplay authority in pure .NET, deterministic behavior proven by tests, and Godot positioned as the host rather than the rules engine.
## Context and Orientation
@@ -83,7 +110,7 @@ Acceptance for this milestone is that the solution builds, the test project runs
This milestone creates the smallest useful simulation loop. At the end of it, a caller can construct a `Simulation`, feed a `TickActionBatch`, advance exactly one tick, and receive a `TickResult` containing events and a deterministic hash. Run the tests and host build from `D:\Code\SideScrollerGame`:
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
.\godot.cmd --headless --path .\godot --build-solutions
.\godot.cmd --headless --quit --path .\godot --build-solutions
Acceptance for this milestone is behavioral. Two fresh `Simulation` instances created with the same game definition, configuration, seed, and empty action stream must produce the same tick numbers, the same state hash, and the same serialized state bytes for a sequence of no-op ticks.
@@ -120,7 +147,7 @@ Acceptance for this milestone is that tests can demonstrate reusable enemy behav
This milestone connects Godot authoring to simulation-safe content. At the end of it, a designer can author level markers in Godot, run a compile step, and produce deterministic runtime data under `content/compiled/` that both tests and the host can consume. Run the milestone commands from `D:\Code\SideScrollerGame`:
.\godot.cmd --headless --path .\godot --build-solutions
.\godot.cmd --headless --quit --path .\godot --build-solutions
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj --filter Content
Acceptance for this milestone is that a level scene with spawn markers, checkpoints, trigger zones, encounter data, and music zones compiles into engine-agnostic definitions, and tests can load that compiled data without touching a live Godot scene tree.
@@ -130,7 +157,7 @@ Acceptance for this milestone is that a level scene with spawn markers, checkpoi
This milestone exposes the simulation to humans. At the end of it, the Godot host translates live input into `TickActionBatch` objects, runs the fixed-step simulation, interpolates between snapshots for rendering, plays overlapping sound effects and cross-faded music, and offers debug transport controls for play, pause, restart, single-step, and fast-forward. Run the proof commands from `D:\Code\SideScrollerGame`:
.\godot.cmd --editor --path .\godot
.\godot.cmd --headless --path .\godot --build-solutions
.\godot.cmd --headless --quit --path .\godot --build-solutions
dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj
Acceptance for this milestone is that a developer can start the Godot project, load a test level, pause the simulation, advance one step at a time, jump forward to a later tick using saved checkpoints, and observe the same state hash sequence that the automated replay tests produce.
@@ -205,7 +232,7 @@ Run all commands from `D:\Code\SideScrollerGame` unless a step states otherwise.
Run:
.\godot.cmd --headless --path .\godot --build-solutions
.\godot.cmd --headless --quit --path .\godot --build-solutions
Expect Godot to regenerate and build C# project artifacts successfully.
@@ -221,13 +248,13 @@ Run all commands from `D:\Code\SideScrollerGame` unless a step states otherwise.
The implementation is acceptable only when a human can see deterministic behavior, not merely when the code compiles. Start with automated proof. `dotnet test tests/SideScrollerGame.Sim.Tests/SideScrollerGame.Sim.Tests.csproj` must pass, and replay-oriented tests must demonstrate that the same seed and action stream yield the same per-tick debug hash sequence. The coverage command in `Concrete Steps` must enforce 100 percent line and branch coverage for gameplay logic inside `SideScrollerGame.Sim`, with only narrow exclusions for non-logic infrastructure such as generated serializers or lookup tables.
Then verify the host integration. `.\godot.cmd --headless --path .\godot --build-solutions` must succeed, proving that the Godot project can consume the simulation assembly. After that, start the editor with `.\godot.cmd --editor --path .\godot`, load a level built from compiled runtime content, and verify the following behaviors manually: play starts the simulation, pause freezes the simulation while leaving the editor responsive, advance-one-step increments the tick exactly once, fast-forward resumes from the nearest saved checkpoint and reaches the requested tick, and replaying the same recorded input reproduces the same state hash display.
Then verify the host integration. `.\godot.cmd --headless --quit --path .\godot --build-solutions` must succeed, proving that the Godot project can consume the simulation assembly. After that, start the editor with `.\godot.cmd --editor --path .\godot`, load a level built from compiled runtime content, and verify the following behaviors manually: play starts the simulation, pause freezes the simulation while leaving the editor responsive, advance-one-step increments the tick exactly once, fast-forward resumes from the nearest saved checkpoint and reaches the requested tick, and replaying the same recorded input reproduces the same state hash display.
The final acceptance scenario is simple and observable. Record a short run in which the player moves, fires, triggers an encounter, collects a pickup, and reaches a checkpoint. Save the replay. Restart the host with the same compiled content and seed, play the replay, and confirm that the tick count, key gameplay events, and final state hash all match the original run. If any of those differ, the implementation is incomplete.
## Idempotence and Recovery
Most steps in this plan are additive and safe to repeat. Re-running `dotnet build`, `dotnet test`, or `.\godot.cmd --headless --path .\godot --build-solutions` is safe and should produce the same result when the repository state has not changed. Re-running the host replay validation is also safe because replays and checkpoints are meant to be reproducible.
Most steps in this plan are additive and safe to repeat. Re-running `dotnet build`, `dotnet test`, or `.\godot.cmd --headless --quit --path .\godot --build-solutions` is safe and should produce the same result when the repository state has not changed. Re-running the host replay validation is also safe because replays and checkpoints are meant to be reproducible.
Project creation commands such as `dotnet new classlib` and `dotnet new xunit` are not naturally idempotent once the destination directories already exist. If those directories have already been created, do not delete them to start over. Instead, inspect the generated files, patch them into the required shape, and continue. The same rule applies to content compilation in this Windows environment: prefer overwriting generated runtime content in place instead of deleting directories.

View File

@@ -0,0 +1,28 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record AxisAlignedBounds
{
public AxisAlignedBounds(FixPointVector2 min, FixPointVector2 max)
{
Min = min;
Max = max;
}
public bool Contains(FixPointVector2 position)
{
return position.m_X >= Min.m_X && position.m_X <= Max.m_X && position.m_Y >= Min.m_Y && position.m_Y <= Max.m_Y;
}
public FixPointVector2 Clamp(FixPointVector2 position)
{
return new(FixPoint16.Clamp(position.m_X, Min.m_X, Max.m_X), FixPoint16.Clamp(position.m_Y, Min.m_Y, Max.m_Y));
}
public FixPointVector2 Min { get; init; }
public FixPointVector2 Max { get; init; }
}

View File

@@ -0,0 +1,18 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record GameDefinition
{
public GameDefinition(LevelDefinition level, ImmutableArray<PlayerDefinition> players)
{
Level = level;
Players = players.IsDefault ? ImmutableArray<PlayerDefinition>.Empty : players;
}
public LevelDefinition Level { get; init; }
public ImmutableArray<PlayerDefinition> Players { get; init; }
}

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record HazardDefinition
{
public HazardDefinition(string id, AxisAlignedBounds bounds, int damagePerTick)
{
Id = id;
Bounds = bounds;
DamagePerTick = damagePerTick;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
public int DamagePerTick { get; init; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record LevelDefinition
{
public LevelDefinition(AxisAlignedBounds worldBounds, ImmutableArray<HazardDefinition> hazards, ImmutableArray<TriggerDefinition> triggers, ImmutableArray<SolidPlatformDefinition> platforms)
{
WorldBounds = worldBounds;
Hazards = hazards.IsDefault ? ImmutableArray<HazardDefinition>.Empty : hazards;
Triggers = triggers.IsDefault ? ImmutableArray<TriggerDefinition>.Empty : triggers;
Platforms = platforms.IsDefault ? ImmutableArray<SolidPlatformDefinition>.Empty : platforms;
}
public AxisAlignedBounds WorldBounds { get; init; }
public ImmutableArray<HazardDefinition> Hazards { get; init; }
public ImmutableArray<TriggerDefinition> Triggers { get; init; }
public ImmutableArray<SolidPlatformDefinition> Platforms { get; init; }
}

View File

@@ -0,0 +1,39 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record PlayerDefinition
{
public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition, int maxHealth, bool usesPlatformerMotion = false, FixPoint16 moveSpeedPerTick = default, FixPoint16 gravityPerTick = default, FixPoint16 jumpVelocityPerTick = default, int coyoteTicks = 0, int jumpBufferTicks = 0)
{
PlayerId = playerId;
SpawnPosition = spawnPosition;
MaxHealth = maxHealth;
UsesPlatformerMotion = usesPlatformerMotion;
MoveSpeedPerTick = moveSpeedPerTick;
GravityPerTick = gravityPerTick;
JumpVelocityPerTick = jumpVelocityPerTick;
CoyoteTicks = coyoteTicks;
JumpBufferTicks = jumpBufferTicks;
}
public PlayerId PlayerId { get; init; }
public FixPointVector2 SpawnPosition { get; init; }
public int MaxHealth { get; init; }
public bool UsesPlatformerMotion { get; init; }
public FixPoint16 MoveSpeedPerTick { get; init; }
public FixPoint16 GravityPerTick { get; init; }
public FixPoint16 JumpVelocityPerTick { get; init; }
public int CoyoteTicks { get; init; }
public int JumpBufferTicks { get; init; }
}

View File

@@ -0,0 +1,17 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record SolidPlatformDefinition
{
public SolidPlatformDefinition(string id, AxisAlignedBounds bounds)
{
Id = id;
Bounds = bounds;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
}

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record TriggerDefinition
{
public TriggerDefinition(string id, AxisAlignedBounds bounds, string kind)
{
Id = id;
Bounds = bounds;
Kind = kind;
}
public string Id { get; init; }
public AxisAlignedBounds Bounds { get; init; }
public string Kind { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace SideScrollerGame.Sim.Input;
public enum InputButton
{
Jump = 0,
FirePrimary = 1,
FireSecondary = 2,
Dash = 3
}

View File

@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Input;
[ExcludeFromCodeCoverage]
public abstract record SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record MoveAxisChanged(PlayerId PlayerId, sbyte X, sbyte Y) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record AimAxisChanged(PlayerId PlayerId, short X, short Y) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record ButtonChanged(PlayerId PlayerId, InputButton Button, bool IsPressed) : SimulationAction;
[ExcludeFromCodeCoverage]
public sealed record WeaponSlotSelected(PlayerId PlayerId, int SlotIndex) : SimulationAction;

View File

@@ -0,0 +1,23 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Input;
[ExcludeFromCodeCoverage]
public sealed record TickActionBatch
{
public TickActionBatch(int tick, ImmutableArray<SimulationAction> actions)
{
Tick = tick;
Actions = actions.IsDefault ? ImmutableArray<SimulationAction>.Empty : actions;
}
public static TickActionBatch Empty(int tick)
{
return new(tick, ImmutableArray<SimulationAction>.Empty);
}
public int Tick { get; init; }
public ImmutableArray<SimulationAction> Actions { get; init; }
}

View File

@@ -0,0 +1,62 @@
namespace SideScrollerGame.Sim.Math;
public readonly struct IntVector2
{
public IntVector2(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
public readonly struct SInt32Vector2
{
public SInt32Vector2(int x, int y)
{
X = x;
Y = y;
}
public int X { get; }
public int Y { get; }
}
public readonly struct SFixPointQuaternion
{
public SFixPointQuaternion(FixPoint16 x, FixPoint16 y, FixPoint16 z, FixPoint16 w)
{
m_X = x;
m_Y = y;
m_Z = z;
m_W = w;
}
public FixPoint16 m_X { get; }
public FixPoint16 m_Y { get; }
public FixPoint16 m_Z { get; }
public FixPoint16 m_W { get; }
}
public readonly struct SFixPointQuaternionTransform
{
public SFixPointQuaternionTransform(SFixPointVector3 position, SFixPointQuaternion orientation, SFixPointVector3 size)
{
m_Position = position;
m_Orientation = orientation;
m_Size = size;
}
public SFixPointVector3 m_Position { get; }
public SFixPointQuaternion m_Orientation { get; }
public SFixPointVector3 m_Size { get; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,349 @@
#if DEBUG
#define RANGE_CHECK
#endif
using System.Runtime.CompilerServices;
namespace SideScrollerGame.Sim.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;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,69 @@
namespace SideScrollerGame.Sim.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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,995 @@
using System;
using System.Diagnostics.Contracts;
using System.Numerics;
using System.Runtime.CompilerServices;
namespace SideScrollerGame.Sim.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

@@ -0,0 +1,34 @@
namespace SideScrollerGame.Sim.Math;
internal static class IntMath
{
public static int Sign(int value)
{
return System.Math.Sign(value);
}
public static int Abs(int value)
{
return System.Math.Abs(value);
}
public static int Min(int left, int right)
{
return System.Math.Min(left, right);
}
public static int Max(int left, int right)
{
return System.Math.Max(left, right);
}
public static int Clamp(int value, int min, int max)
{
return System.Math.Clamp(value, min, max);
}
public static long Sqrt(long value)
{
return (long)System.Math.Sqrt(value);
}
}

View File

@@ -0,0 +1,119 @@
using System;
namespace SideScrollerGame.Sim.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;
}

View File

@@ -0,0 +1,3 @@
namespace SideScrollerGame.Sim;
public readonly record struct PlayerId(int Value);

View File

@@ -0,0 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Input;
namespace SideScrollerGame.Sim.Replay;
[ExcludeFromCodeCoverage]
public sealed record RecordedTick
{
public RecordedTick(TickActionBatch actionBatch, int expectedStateHash)
{
ActionBatch = actionBatch;
ExpectedStateHash = expectedStateHash;
}
public TickActionBatch ActionBatch { get; init; }
public int ExpectedStateHash { get; init; }
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Replay;
public static class ReplayPlayer
{
public static ImmutableArray<int> Play(ReplayRecord replay, GameDefinition gameDefinition, SimulationConfig config)
{
if (replay.ContentHash != GameDefinitionHasher.Compute(gameDefinition))
throw new InvalidOperationException("Replay content hash does not match the supplied game definition.");
if (replay.TicksPerSecond != config.TicksPerSecond)
throw new InvalidOperationException("Replay tick rate does not match the supplied simulation config.");
var hashes = ImmutableArray.CreateBuilder<int>(replay.Ticks.Length);
Simulation simulation = new(gameDefinition, config, replay.Seed);
foreach (var recordedTick in replay.Ticks)
{
var result = simulation.Step(recordedTick.ActionBatch);
if (result.StateHash != recordedTick.ExpectedStateHash)
throw new InvalidOperationException($"Replay diverged at tick {recordedTick.ActionBatch.Tick}.");
hashes.Add(result.StateHash);
}
return hashes.MoveToImmutable();
}
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Replay;
[ExcludeFromCodeCoverage]
public sealed record ReplayRecord
{
public ReplayRecord(int contentHash, int seed, int ticksPerSecond, ImmutableArray<RecordedTick> ticks)
{
ContentHash = contentHash;
Seed = seed;
TicksPerSecond = ticksPerSecond;
Ticks = ticks.IsDefault ? ImmutableArray<RecordedTick>.Empty : ticks;
}
public int ContentHash { get; init; }
public int Seed { get; init; }
public int TicksPerSecond { get; init; }
public ImmutableArray<RecordedTick> Ticks { get; init; }
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Runtime;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Replay;
public sealed class ReplayRecorder
{
public ReplayRecorder()
{
m_Ticks = new();
}
public void Append(TickActionBatch actionBatch, TickResult tickResult)
{
m_Ticks.Add(new(actionBatch, tickResult.StateHash));
}
public ReplayRecord Build(GameDefinition gameDefinition, SimulationConfig config, int seed)
{
return new(GameDefinitionHasher.Compute(gameDefinition), seed, config.TicksPerSecond, m_Ticks.ToImmutableArray());
}
private readonly List<RecordedTick> m_Ticks;
}

View File

@@ -0,0 +1,33 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record PlayerSnapshot
{
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, int health, FixPoint16 verticalVelocity, bool isGrounded)
{
PlayerId = playerId;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
Health = health;
VerticalVelocity = verticalVelocity;
IsGrounded = isGrounded;
}
public PlayerId PlayerId { get; init; }
public FixPointVector2 Position { get; init; }
public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; }
public int Health { get; init; }
public FixPoint16 VerticalVelocity { get; init; }
public bool IsGrounded { get; init; }
}

View File

@@ -0,0 +1,133 @@
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Runtime;
public sealed class PlayerState
{
public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask, int health, FixPoint16 verticalVelocity, bool isGrounded, int lastGroundedTick, int bufferedJumpTick)
{
PlayerId = playerId;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
AimAxisX = aimAxisX;
AimAxisY = aimAxisY;
SelectedWeaponSlot = selectedWeaponSlot;
ButtonMask = buttonMask;
Health = health;
VerticalVelocity = verticalVelocity;
IsGrounded = isGrounded;
LastGroundedTick = lastGroundedTick;
BufferedJumpTick = bufferedJumpTick;
}
public PlayerState Clone()
{
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask, Health, VerticalVelocity, IsGrounded, LastGroundedTick, BufferedJumpTick);
}
public void SetMoveAxis(sbyte x, sbyte y)
{
MoveAxisX = x;
MoveAxisY = y;
}
public void SetAimAxis(short x, short y)
{
AimAxisX = x;
AimAxisY = y;
}
public void SetButton(InputButton button, bool isPressed)
{
var mask = 1 << (int)button;
ButtonMask = isPressed ? ButtonMask | mask : ButtonMask & ~mask;
}
public void SelectWeaponSlot(int slotIndex)
{
SelectedWeaponSlot = slotIndex;
}
public bool IsButtonPressed(InputButton button)
{
return (ButtonMask & (1 << (int)button)) != 0;
}
public void Advance()
{
Position += new FixPointVector2(MoveAxisX, MoveAxisY);
}
public void ApplyDamage(int damage)
{
Health = System.Math.Max(0, Health - damage);
}
public void BufferJump(int tick)
{
BufferedJumpTick = tick;
}
public bool HasBufferedJump(int tick, int jumpBufferTicks)
{
return BufferedJumpTick >= 0 && tick - BufferedJumpTick <= jumpBufferTicks;
}
public void ConsumeBufferedJump()
{
BufferedJumpTick = -1;
}
public void SetPosition(FixPointVector2 position)
{
Position = position;
}
public void SetVerticalVelocity(FixPoint16 verticalVelocity)
{
VerticalVelocity = verticalVelocity;
}
public void SetGrounded(bool isGrounded, int tick)
{
IsGrounded = isGrounded;
if (isGrounded)
LastGroundedTick = tick;
}
public void LeaveGround(int tick)
{
if (IsGrounded)
LastGroundedTick = tick;
IsGrounded = false;
}
public PlayerId PlayerId { get; }
public FixPointVector2 Position { get; private set; }
public sbyte MoveAxisX { get; private set; }
public sbyte MoveAxisY { get; private set; }
public short AimAxisX { get; private set; }
public short AimAxisY { get; private set; }
public int SelectedWeaponSlot { get; private set; }
public int ButtonMask { get; private set; }
public int Health { get; private set; }
public FixPoint16 VerticalVelocity { get; private set; }
public bool IsGrounded { get; private set; }
public int LastGroundedTick { get; private set; }
public int BufferedJumpTick { get; private set; }
}

View File

@@ -0,0 +1,20 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record SimulationEvent
{
public SimulationEvent(string kind, int tick, PlayerId playerId)
{
Kind = kind;
Tick = tick;
PlayerId = playerId;
}
public string Kind { get; init; }
public int Tick { get; init; }
public PlayerId PlayerId { get; init; }
}

View File

@@ -0,0 +1,64 @@
using System.Collections.Immutable;
namespace SideScrollerGame.Sim.Runtime;
public sealed class SimulationState
{
public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray<PlayerState> players, ImmutableHashSet<string> activatedTriggerIds)
{
Tick = tick;
Seed = seed;
RandomState = randomState;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerState>.Empty : players;
ActivatedTriggerIds = activatedTriggerIds == default ? ImmutableHashSet<string>.Empty : activatedTriggerIds;
}
public SimulationState Clone()
{
var builder = ImmutableArray.CreateBuilder<PlayerState>(Players.Length);
foreach (var player in Players)
builder.Add(player.Clone());
return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable(), ActivatedTriggerIds);
}
public PlayerState GetRequiredPlayer(PlayerId playerId)
{
foreach (var player in Players)
{
if (player.PlayerId == playerId)
return player;
}
throw new InvalidOperationException($"Unknown player id {playerId.Value}.");
}
public void AdvanceTick(int tick, ulong randomState, ulong lastRandomValue)
{
Tick = tick;
RandomState = randomState;
LastRandomValue = lastRandomValue;
}
public bool ActivateTrigger(string triggerId)
{
if (ActivatedTriggerIds.Contains(triggerId))
return false;
ActivatedTriggerIds = ActivatedTriggerIds.Add(triggerId);
return true;
}
public int Tick { get; private set; }
public int Seed { get; }
public ulong RandomState { get; private set; }
public ulong LastRandomValue { get; private set; }
public ImmutableArray<PlayerState> Players { get; }
public ImmutableHashSet<string> ActivatedTriggerIds { get; private set; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record TickResult
{
public TickResult(ImmutableArray<SimulationEvent> events, int stateHash, WorldSnapshot previousSnapshot, WorldSnapshot currentSnapshot)
{
Events = events.IsDefault ? ImmutableArray<SimulationEvent>.Empty : events;
StateHash = stateHash;
PreviousSnapshot = previousSnapshot;
CurrentSnapshot = currentSnapshot;
}
public ImmutableArray<SimulationEvent> Events { get; init; }
public int StateHash { get; init; }
public WorldSnapshot PreviousSnapshot { get; init; }
public WorldSnapshot CurrentSnapshot { get; init; }
}

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record WorldSnapshot
{
public WorldSnapshot(int tick, int stateHash, ulong lastRandomValue, ImmutableArray<PlayerSnapshot> players)
{
Tick = tick;
StateHash = stateHash;
LastRandomValue = lastRandomValue;
Players = players.IsDefault ? ImmutableArray<PlayerSnapshot>.Empty : players;
}
public int Tick { get; init; }
public int StateHash { get; init; }
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerSnapshot> Players { get; init; }
}

View File

@@ -0,0 +1,25 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class DeterministicHash
{
public static int Compute(byte[] data)
{
unchecked
{
const uint offset = 2166136261;
const uint prime = 16777619;
var hash = offset;
foreach (var value in data)
{
hash ^= value;
hash *= prime;
}
return (int)hash;
}
}
}

View File

@@ -0,0 +1,172 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Definitions;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class GameDefinitionHasher
{
[ExcludeFromCodeCoverage]
private sealed record GameDefinitionDocument
{
public LevelDefinitionDocument Level { get; init; } = null!;
public ImmutableArray<PlayerDefinitionDocument> Players { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record PlayerDefinitionDocument
{
public int PlayerId { get; init; }
public int SpawnX { get; init; }
public int SpawnY { get; init; }
public int MaxHealth { get; init; }
public bool UsesPlatformerMotion { get; init; }
public int MoveSpeedPerTick { get; init; }
public int GravityPerTick { get; init; }
public int JumpVelocityPerTick { get; init; }
public int CoyoteTicks { get; init; }
public int JumpBufferTicks { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record LevelDefinitionDocument
{
public BoundsDocument WorldBounds { get; init; } = null!;
public ImmutableArray<HazardDefinitionDocument> Hazards { get; init; }
public ImmutableArray<TriggerDefinitionDocument> Triggers { get; init; }
public ImmutableArray<SolidPlatformDefinitionDocument> Platforms { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record BoundsDocument
{
public int MinX { get; init; }
public int MinY { get; init; }
public int MaxX { get; init; }
public int MaxY { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record HazardDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
public int DamagePerTick { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record TriggerDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
public string Kind { get; init; } = string.Empty;
}
[ExcludeFromCodeCoverage]
private sealed record SolidPlatformDefinitionDocument
{
public string Id { get; init; } = string.Empty;
public BoundsDocument Bounds { get; init; } = null!;
}
public static int Compute(GameDefinition gameDefinition)
{
List<PlayerDefinitionDocument> players = new(gameDefinition.Players.Length);
foreach (var player in gameDefinition.Players)
{
players.Add(new()
{
PlayerId = player.PlayerId.Value,
SpawnX = player.SpawnPosition.m_X.m_Value,
SpawnY = player.SpawnPosition.m_Y.m_Value,
MaxHealth = player.MaxHealth,
UsesPlatformerMotion = player.UsesPlatformerMotion,
MoveSpeedPerTick = player.MoveSpeedPerTick.m_Value,
GravityPerTick = player.GravityPerTick.m_Value,
JumpVelocityPerTick = player.JumpVelocityPerTick.m_Value,
CoyoteTicks = player.CoyoteTicks,
JumpBufferTicks = player.JumpBufferTicks
});
}
List<HazardDefinitionDocument> hazards = new(gameDefinition.Level.Hazards.Length);
foreach (var hazard in gameDefinition.Level.Hazards)
{
hazards.Add(new()
{
Id = hazard.Id,
Bounds = ToDocument(hazard.Bounds),
DamagePerTick = hazard.DamagePerTick
});
}
List<TriggerDefinitionDocument> triggers = new(gameDefinition.Level.Triggers.Length);
foreach (var trigger in gameDefinition.Level.Triggers)
{
triggers.Add(new()
{
Id = trigger.Id,
Bounds = ToDocument(trigger.Bounds),
Kind = trigger.Kind
});
}
List<SolidPlatformDefinitionDocument> platforms = new(gameDefinition.Level.Platforms.Length);
foreach (var platform in gameDefinition.Level.Platforms)
{
platforms.Add(new()
{
Id = platform.Id,
Bounds = ToDocument(platform.Bounds)
});
}
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument
{
Level = new()
{
WorldBounds = ToDocument(gameDefinition.Level.WorldBounds),
Hazards = hazards.ToImmutableArray(),
Triggers = triggers.ToImmutableArray(),
Platforms = platforms.ToImmutableArray()
},
Players = players.ToImmutableArray()
});
return DeterministicHash.Compute(bytes);
}
private static BoundsDocument ToDocument(AxisAlignedBounds bounds)
{
return new()
{
MinX = bounds.Min.m_X.m_Value,
MinY = bounds.Min.m_Y.m_Value,
MaxX = bounds.Max.m_X.m_Value,
MaxY = bounds.Max.m_Y.m_Value
};
}
}

View File

@@ -0,0 +1,146 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Replay;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
public static class ReplayRecordSerializer
{
[ExcludeFromCodeCoverage]
private sealed record ReplayRecordDocument
{
public int Version { get; init; }
public int ContentHash { get; init; }
public int Seed { get; init; }
public int TicksPerSecond { get; init; }
public ImmutableArray<RecordedTickDocument> Ticks { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record RecordedTickDocument
{
public int Tick { get; init; }
public int ExpectedStateHash { get; init; }
public ImmutableArray<SimulationActionDocument> Actions { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record SimulationActionDocument
{
public string Kind { get; init; } = string.Empty;
public int PlayerId { get; init; }
public int X { get; init; }
public int Y { get; init; }
public InputButton Button { get; init; }
public bool IsPressed { get; init; }
public int SlotIndex { get; init; }
}
public static byte[] Serialize(ReplayRecord replayRecord)
{
List<RecordedTickDocument> ticks = new(replayRecord.Ticks.Length);
foreach (var tick in replayRecord.Ticks)
{
List<SimulationActionDocument> actions = new(tick.ActionBatch.Actions.Length);
foreach (var action in tick.ActionBatch.Actions)
actions.Add(ToDocument(action));
ticks.Add(new()
{
Tick = tick.ActionBatch.Tick,
ExpectedStateHash = tick.ExpectedStateHash,
Actions = actions.ToImmutableArray()
});
}
return JsonSerializer.SerializeToUtf8Bytes(new ReplayRecordDocument
{
Version = SimulationDefaults.ReplayFormatVersion,
ContentHash = replayRecord.ContentHash,
Seed = replayRecord.Seed,
TicksPerSecond = replayRecord.TicksPerSecond,
Ticks = ticks.ToImmutableArray()
});
}
public static ReplayRecord Deserialize(byte[] data)
{
var document = JsonSerializer.Deserialize<ReplayRecordDocument>(data) ?? throw new InvalidOperationException("Replay payload was empty.");
if (document.Version != SimulationDefaults.ReplayFormatVersion)
throw new NotSupportedException($"Unsupported replay version {document.Version}.");
var ticks = ImmutableArray.CreateBuilder<RecordedTick>(document.Ticks.Length);
foreach (var tick in document.Ticks)
{
var actions = ImmutableArray.CreateBuilder<SimulationAction>(tick.Actions.Length);
foreach (var action in tick.Actions)
actions.Add(ToAction(action));
ticks.Add(new(new(tick.Tick, actions.MoveToImmutable()), tick.ExpectedStateHash));
}
return new(document.ContentHash, document.Seed, document.TicksPerSecond, ticks.MoveToImmutable());
}
private static SimulationActionDocument ToDocument(SimulationAction action)
{
return action switch
{
MoveAxisChanged moveAxisChanged => new()
{
Kind = nameof(MoveAxisChanged),
PlayerId = moveAxisChanged.PlayerId.Value,
X = moveAxisChanged.X,
Y = moveAxisChanged.Y
},
AimAxisChanged aimAxisChanged => new()
{
Kind = nameof(AimAxisChanged),
PlayerId = aimAxisChanged.PlayerId.Value,
X = aimAxisChanged.X,
Y = aimAxisChanged.Y
},
ButtonChanged buttonChanged => new()
{
Kind = nameof(ButtonChanged),
PlayerId = buttonChanged.PlayerId.Value,
Button = buttonChanged.Button,
IsPressed = buttonChanged.IsPressed
},
WeaponSlotSelected weaponSlotSelected => new()
{
Kind = nameof(WeaponSlotSelected),
PlayerId = weaponSlotSelected.PlayerId.Value,
SlotIndex = weaponSlotSelected.SlotIndex
},
_ => throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.")
};
}
private static SimulationAction ToAction(SimulationActionDocument action)
{
return action.Kind switch
{
nameof(MoveAxisChanged) => new MoveAxisChanged(new(action.PlayerId), checked((sbyte)action.X), checked((sbyte)action.Y)),
nameof(AimAxisChanged) => new AimAxisChanged(new(action.PlayerId), checked((short)action.X), checked((short)action.Y)),
nameof(ButtonChanged) => new ButtonChanged(new(action.PlayerId), action.Button, action.IsPressed),
nameof(WeaponSlotSelected) => new WeaponSlotSelected(new(action.PlayerId), action.SlotIndex),
_ => throw new NotSupportedException($"Unsupported action kind {action.Kind}.")
};
}
}

View File

@@ -0,0 +1,110 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
namespace SideScrollerGame.Sim.Serialization;
[ExcludeFromCodeCoverage]
internal static class SimulationStateSerializer
{
[ExcludeFromCodeCoverage]
private sealed record SimulationStateDocument
{
public int Version { get; init; }
public int Tick { get; init; }
public int Seed { get; init; }
public ulong RandomState { get; init; }
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerStateDocument> Players { get; init; }
public ImmutableArray<string> ActivatedTriggerIds { get; init; }
}
[ExcludeFromCodeCoverage]
private sealed record PlayerStateDocument
{
public int PlayerId { get; init; }
public int PositionX { get; init; }
public int PositionY { get; init; }
public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; }
public short AimAxisX { get; init; }
public short AimAxisY { get; init; }
public int SelectedWeaponSlot { get; init; }
public int ButtonMask { get; init; }
public int Health { get; init; }
public int VerticalVelocity { get; init; }
public bool IsGrounded { get; init; }
public int LastGroundedTick { get; init; }
public int BufferedJumpTick { get; init; }
}
public static byte[] Serialize(SimulationState state)
{
List<PlayerStateDocument> players = new(state.Players.Length);
foreach (var player in state.Players)
{
players.Add(new()
{
PlayerId = player.PlayerId.Value,
PositionX = player.Position.m_X.m_Value,
PositionY = player.Position.m_Y.m_Value,
MoveAxisX = player.MoveAxisX,
MoveAxisY = player.MoveAxisY,
AimAxisX = player.AimAxisX,
AimAxisY = player.AimAxisY,
SelectedWeaponSlot = player.SelectedWeaponSlot,
ButtonMask = player.ButtonMask,
Health = player.Health,
VerticalVelocity = player.VerticalVelocity.m_Value,
IsGrounded = player.IsGrounded,
LastGroundedTick = player.LastGroundedTick,
BufferedJumpTick = player.BufferedJumpTick
});
}
return JsonSerializer.SerializeToUtf8Bytes(new SimulationStateDocument
{
Version = SimulationDefaults.StateFormatVersion,
Tick = state.Tick,
Seed = state.Seed,
RandomState = state.RandomState,
LastRandomValue = state.LastRandomValue,
Players = players.ToImmutableArray(),
ActivatedTriggerIds = state.ActivatedTriggerIds.Order(StringComparer.Ordinal).ToImmutableArray()
});
}
public static SimulationState Deserialize(byte[] data)
{
var document = JsonSerializer.Deserialize<SimulationStateDocument>(data) ?? throw new InvalidOperationException("Simulation state payload was empty.");
if (document.Version != SimulationDefaults.StateFormatVersion)
throw new NotSupportedException($"Unsupported simulation state version {document.Version}.");
var players = ImmutableArray.CreateBuilder<PlayerState>(document.Players.Length);
foreach (var player in document.Players)
players.Add(new(new(player.PlayerId), new(new() { m_Value = player.PositionX }, new FixPoint16 { m_Value = player.PositionY }), player.MoveAxisX, player.MoveAxisY, player.AimAxisX, player.AimAxisY, player.SelectedWeaponSlot, player.ButtonMask, player.Health, new() { m_Value = player.VerticalVelocity }, player.IsGrounded, player.LastGroundedTick, player.BufferedJumpTick));
return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable(), document.ActivatedTriggerIds.ToImmutableHashSet(StringComparer.Ordinal));
}
}

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,410 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
using SideScrollerGame.Sim.Serialization;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim;
public sealed class Simulation
{
public Simulation(GameDefinition gameDefinition, SimulationConfig config, int seed) : this(gameDefinition, config, CreateInitialState(gameDefinition, seed), true)
{
}
private Simulation(GameDefinition gameDefinition, SimulationConfig config, SimulationState initialState, bool enableVerification)
{
m_GameDefinition = gameDefinition ?? throw new ArgumentNullException(nameof(gameDefinition));
m_Config = config ?? throw new ArgumentNullException(nameof(config));
m_EnableVerification = enableVerification;
ValidateDefinitions(gameDefinition);
CurrentState = initialState;
var initialHash = ComputeStateHash(initialState);
PreviousSnapshot = CreateSnapshot(initialState, initialHash);
CurrentSnapshot = PreviousSnapshot;
}
public TickResult Step(in TickActionBatch actions)
{
if (actions.Tick != CurrentTick + 1)
throw new InvalidOperationException($"Expected tick {CurrentTick + 1} but received {actions.Tick}.");
TickResult? cloneResult = null;
if (m_EnableVerification && m_Config.VerificationMode == VerificationMode.RoundTripAndStepClone)
{
var clone = LoadStateCore(SaveState(), m_GameDefinition, m_Config, false);
cloneResult = clone.Step(actions);
}
PreviousSnapshot = CurrentSnapshot;
List<SimulationEvent> events = new();
ApplyActions(actions.Tick, actions);
var nextRandomState = AdvanceRandom();
AdvancePlayers(actions.Tick, events);
ResolveBounds(actions.Tick, events);
ResolveHazards(actions.Tick, events);
ResolveTriggers(actions.Tick, events);
CurrentState.AdvanceTick(actions.Tick, nextRandomState, nextRandomState);
var stateHash = ComputeStateHash(CurrentState);
CurrentSnapshot = CreateSnapshot(CurrentState, stateHash);
ValidateRoundTripState(stateHash);
ValidateCloneResult(cloneResult, stateHash, actions.Tick);
return new(events.ToImmutableArray(), stateHash, PreviousSnapshot, CurrentSnapshot);
}
public byte[] SaveState()
{
return SimulationStateSerializer.Serialize(CurrentState);
}
public static Simulation LoadState(byte[] data, GameDefinition gameDefinition, SimulationConfig config)
{
return LoadStateCore(data, gameDefinition, config, true);
}
private static Simulation LoadStateCore(byte[] data, GameDefinition gameDefinition, SimulationConfig config, bool enableVerification)
{
var state = SimulationStateSerializer.Deserialize(data);
return new(gameDefinition, config, state, enableVerification);
}
private static SimulationState CreateInitialState(GameDefinition gameDefinition, int seed)
{
ArgumentNullException.ThrowIfNull(gameDefinition);
ValidateDefinitions(gameDefinition);
var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length);
foreach (var player in gameDefinition.Players)
{
var isGrounded = IsSupported(gameDefinition.Level, player.SpawnPosition);
players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0, player.MaxHealth, FixPoint16.Zero, isGrounded, isGrounded ? 0 : -1, -1));
}
var normalizedSeed = NormalizeSeed(seed);
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable(), ImmutableHashSet<string>.Empty);
}
private static ulong NormalizeSeed(int seed)
{
return seed == 0 ? 1UL : unchecked((uint)seed);
}
private static void ValidateDefinitions(GameDefinition gameDefinition)
{
HashSet<int> seen = new();
foreach (var player in gameDefinition.Players)
{
if (!seen.Add(player.PlayerId.Value))
throw new InvalidOperationException($"Duplicate player id {player.PlayerId.Value}.");
if (player.MaxHealth <= 0)
throw new InvalidOperationException($"Player {player.PlayerId.Value} must have positive health.");
if (!gameDefinition.Level.WorldBounds.Contains(player.SpawnPosition))
throw new InvalidOperationException($"Player {player.PlayerId.Value} spawn must start inside world bounds.");
if (player.MoveSpeedPerTick < FixPoint16.Zero)
throw new InvalidOperationException($"Player {player.PlayerId.Value} move speed must be non-negative.");
if (player.GravityPerTick < FixPoint16.Zero)
throw new InvalidOperationException($"Player {player.PlayerId.Value} gravity must be non-negative.");
if (player.JumpVelocityPerTick < FixPoint16.Zero)
throw new InvalidOperationException($"Player {player.PlayerId.Value} jump velocity must be non-negative.");
if (player.CoyoteTicks < 0)
throw new InvalidOperationException($"Player {player.PlayerId.Value} coyote ticks must be non-negative.");
if (player.JumpBufferTicks < 0)
throw new InvalidOperationException($"Player {player.PlayerId.Value} jump buffer ticks must be non-negative.");
}
}
private static int ComputeStateHash(SimulationState state)
{
return DeterministicHash.Compute(SimulationStateSerializer.Serialize(state));
}
private static WorldSnapshot CreateSnapshot(SimulationState state, int stateHash)
{
var players = ImmutableArray.CreateBuilder<PlayerSnapshot>(state.Players.Length);
foreach (var player in state.Players)
players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY, player.Health, player.VerticalVelocity, player.IsGrounded));
return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable());
}
private void ApplyActions(int tick, TickActionBatch actions)
{
foreach (var action in actions.Actions)
{
switch (action)
{
case MoveAxisChanged moveAxisChanged:
CurrentState.GetRequiredPlayer(moveAxisChanged.PlayerId).SetMoveAxis(moveAxisChanged.X, moveAxisChanged.Y);
break;
case AimAxisChanged aimAxisChanged:
CurrentState.GetRequiredPlayer(aimAxisChanged.PlayerId).SetAimAxis(aimAxisChanged.X, aimAxisChanged.Y);
break;
case ButtonChanged buttonChanged:
var player = CurrentState.GetRequiredPlayer(buttonChanged.PlayerId);
var wasPressed = player.IsButtonPressed(buttonChanged.Button);
player.SetButton(buttonChanged.Button, buttonChanged.IsPressed);
if (buttonChanged.Button == InputButton.Jump && buttonChanged.IsPressed && !wasPressed)
player.BufferJump(tick);
break;
case WeaponSlotSelected weaponSlotSelected:
CurrentState.GetRequiredPlayer(weaponSlotSelected.PlayerId).SelectWeaponSlot(weaponSlotSelected.SlotIndex);
break;
default:
throw new NotSupportedException($"Unsupported action type {action.GetType().Name}.");
}
}
}
private void AdvancePlayers(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
var definition = GetPlayerDefinition(player.PlayerId);
if (definition.UsesPlatformerMotion)
AdvancePlatformerPlayer(player, definition, tick, events);
else if (player.MoveAxisX != 0 || player.MoveAxisY != 0)
{
player.Advance();
events.Add(new("PlayerMoved", tick, player.PlayerId));
}
}
}
private void ResolveBounds(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
if (GetPlayerDefinition(player.PlayerId).UsesPlatformerMotion)
continue;
var clamped = m_GameDefinition.Level.WorldBounds.Clamp(player.Position);
if (clamped != player.Position)
{
player.SetPosition(clamped);
events.Add(new("PlayerClamped", tick, player.PlayerId));
}
}
}
private void AdvancePlatformerPlayer(PlayerState player, PlayerDefinition definition, int tick, List<SimulationEvent> events)
{
var previousPosition = player.Position;
var nextPosition = previousPosition;
nextPosition.m_X += definition.MoveSpeedPerTick * player.MoveAxisX;
TryConsumeBufferedJump(player, definition, tick, events);
if (!player.IsGrounded || !player.VerticalVelocity.IsZero())
player.SetVerticalVelocity(player.VerticalVelocity + definition.GravityPerTick);
nextPosition.m_Y += player.VerticalVelocity;
var clampedX = FixPoint16.Clamp(nextPosition.m_X, m_GameDefinition.Level.WorldBounds.Min.m_X, m_GameDefinition.Level.WorldBounds.Max.m_X);
if (clampedX != nextPosition.m_X)
{
nextPosition.m_X = clampedX;
events.Add(new("PlayerClamped", tick, player.PlayerId));
}
ResolvePlatformerVerticalMovement(player, definition, previousPosition, ref nextPosition, tick, events);
player.SetPosition(nextPosition);
if (player.Position != previousPosition)
events.Add(new("PlayerMoved", tick, player.PlayerId));
}
private void ResolvePlatformerVerticalMovement(PlayerState player, PlayerDefinition definition, FixPointVector2 previousPosition, ref FixPointVector2 nextPosition, int tick, List<SimulationEvent> events)
{
if (TryFindLandingY(previousPosition, nextPosition, out var landingY))
{
nextPosition.m_Y = landingY;
player.SetVerticalVelocity(FixPoint16.Zero);
if (!player.IsGrounded)
events.Add(new("PlayerLanded", tick, player.PlayerId));
player.SetGrounded(true, tick);
if (TryConsumeBufferedJump(player, definition, tick, events))
{
nextPosition.m_Y += player.VerticalVelocity;
nextPosition.m_Y = FixPoint16.Max(nextPosition.m_Y, m_GameDefinition.Level.WorldBounds.Min.m_Y);
}
return;
}
var clampedY = FixPoint16.Clamp(nextPosition.m_Y, m_GameDefinition.Level.WorldBounds.Min.m_Y, m_GameDefinition.Level.WorldBounds.Max.m_Y);
if (clampedY != nextPosition.m_Y)
{
nextPosition.m_Y = clampedY;
player.SetVerticalVelocity(FixPoint16.Zero);
events.Add(new("PlayerClamped", tick, player.PlayerId));
}
if (!IsSupported(m_GameDefinition.Level, nextPosition) && player.IsGrounded)
player.LeaveGround(tick);
}
private bool TryConsumeBufferedJump(PlayerState player, PlayerDefinition definition, int tick, List<SimulationEvent> events)
{
if (!player.HasBufferedJump(tick, definition.JumpBufferTicks))
return false;
if (!player.IsGrounded && (player.LastGroundedTick < 0 || tick - player.LastGroundedTick > definition.CoyoteTicks))
return false;
player.SetVerticalVelocity(-definition.JumpVelocityPerTick);
player.SetGrounded(false, tick);
player.ConsumeBufferedJump();
events.Add(new("PlayerJumped", tick, player.PlayerId));
return true;
}
private bool TryFindLandingY(FixPointVector2 previousPosition, FixPointVector2 nextPosition, out FixPoint16 landingY)
{
landingY = default;
var found = false;
var worldFloorY = m_GameDefinition.Level.WorldBounds.Max.m_Y;
if (previousPosition.m_Y <= worldFloorY && nextPosition.m_Y >= worldFloorY)
{
landingY = worldFloorY;
found = true;
}
foreach (var platform in m_GameDefinition.Level.Platforms)
{
var topY = platform.Bounds.Min.m_Y;
if (previousPosition.m_Y > topY || nextPosition.m_Y < topY)
continue;
if (nextPosition.m_X < platform.Bounds.Min.m_X || nextPosition.m_X > platform.Bounds.Max.m_X)
continue;
if (!found || topY < landingY)
{
landingY = topY;
found = true;
}
}
return found;
}
private static bool IsSupported(LevelDefinition levelDefinition, FixPointVector2 position)
{
if (position.m_Y == levelDefinition.WorldBounds.Max.m_Y)
return true;
foreach (var platform in levelDefinition.Platforms)
{
if (position.m_Y == platform.Bounds.Min.m_Y && position.m_X >= platform.Bounds.Min.m_X && position.m_X <= platform.Bounds.Max.m_X)
return true;
}
return false;
}
private PlayerDefinition GetPlayerDefinition(PlayerId playerId)
{
foreach (var player in m_GameDefinition.Players)
{
if (player.PlayerId == playerId)
return player;
}
throw new InvalidOperationException($"Unknown player id {playerId.Value}.");
}
private void ResolveHazards(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
foreach (var hazard in m_GameDefinition.Level.Hazards)
{
if (!hazard.Bounds.Contains(player.Position))
continue;
player.ApplyDamage(hazard.DamagePerTick);
events.Add(new("PlayerDamaged", tick, player.PlayerId));
}
}
}
private void ResolveTriggers(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
foreach (var trigger in m_GameDefinition.Level.Triggers)
{
if (!trigger.Bounds.Contains(player.Position) || !CurrentState.ActivateTrigger(trigger.Id))
continue;
events.Add(new(trigger.Kind, tick, player.PlayerId));
}
}
}
private ulong AdvanceRandom()
{
SIntRandom random = new(CurrentState.RandomState);
random.Next();
return random.Seed;
}
[ExcludeFromCodeCoverage]
private void ValidateRoundTripState(int expectedHash)
{
if (!m_EnableVerification || m_Config.VerificationMode == VerificationMode.None)
return;
var currentBytes = SaveState();
var roundTrip = LoadStateCore(currentBytes, m_GameDefinition, m_Config, false);
var roundTrippedBytes = roundTrip.SaveState();
if (!currentBytes.AsSpan().SequenceEqual(roundTrippedBytes))
throw new SimulationVerificationException("Round-trip serialization changed the saved state payload.");
var roundTripHash = ComputeStateHash(roundTrip.CurrentState);
if (roundTripHash != expectedHash)
throw new SimulationVerificationException("Round-trip serialization changed the state hash.");
}
[ExcludeFromCodeCoverage]
private static void ValidateCloneResult(TickResult? cloneResult, int stateHash, int tick)
{
if (cloneResult is null)
return;
if (cloneResult.StateHash != stateHash)
throw new SimulationVerificationException($"Clone replay diverged at tick {tick}.");
}
public int CurrentTick => CurrentState.Tick;
public SimulationState CurrentState { get; }
public WorldSnapshot PreviousSnapshot { get; private set; }
public WorldSnapshot CurrentSnapshot { get; private set; }
private readonly SimulationConfig m_Config;
private readonly bool m_EnableVerification;
private readonly GameDefinition m_GameDefinition;
}

View File

@@ -0,0 +1,23 @@
using System.Diagnostics.CodeAnalysis;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim;
[ExcludeFromCodeCoverage]
public sealed record SimulationConfig
{
public SimulationConfig(int ticksPerSecond, VerificationMode verificationMode)
{
if (ticksPerSecond <= 0)
throw new ArgumentOutOfRangeException(nameof(ticksPerSecond));
TicksPerSecond = ticksPerSecond;
VerificationMode = verificationMode;
}
public int TicksPerSecond { get; init; }
public VerificationMode VerificationMode { get; init; }
public static SimulationConfig Default { get; } = new(SimulationDefaults.DefaultTicksPerSecond, VerificationMode.None);
}

View File

@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim;
[ExcludeFromCodeCoverage]
public static class SimulationDefaults
{
public const int DefaultTicksPerSecond = 60;
public const int ReplayFormatVersion = 1;
public const int StateFormatVersion = 1;
}

View File

@@ -0,0 +1,11 @@
using System.Diagnostics.CodeAnalysis;
namespace SideScrollerGame.Sim.Verification;
[ExcludeFromCodeCoverage]
public sealed class SimulationVerificationException : Exception
{
public SimulationVerificationException(string message) : base(message)
{
}
}

View File

@@ -0,0 +1,8 @@
namespace SideScrollerGame.Sim.Verification;
public enum VerificationMode
{
None,
RoundTripState,
RoundTripAndStepClone
}

View File

@@ -0,0 +1,16 @@
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Tests;
public sealed class DeterministicMathSmokeTests
{
[Fact]
public void FixPointAverage_UsesSimulationMathTypes()
{
FixPoint16[] values = [new(1), new(2), new(3)];
var average = values.Average();
Assert.Equal(new(2), average);
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<Exclude>[SideScrollerGame.Sim]SideScrollerGame.Sim.Math.*,[SideScrollerGame.Sim]SideScrollerGame.Sim.SimulationDefaults</Exclude>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="coverlet.msbuild" Version="6.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\SideScrollerGame.Sim\SideScrollerGame.Sim.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,19 @@
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationConfigTests
{
[Fact]
public void Default_UsesRepositoryDefaults()
{
Assert.Equal(SimulationDefaults.DefaultTicksPerSecond, SimulationConfig.Default.TicksPerSecond);
Assert.Equal(VerificationMode.None, SimulationConfig.Default.VerificationMode);
}
[Fact]
public void Constructor_RejectsNonPositiveTicksPerSecond()
{
Assert.Throws<ArgumentOutOfRangeException>(() => new SimulationConfig(0, VerificationMode.None));
}
}

View File

@@ -0,0 +1,119 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Replay;
using SideScrollerGame.Sim.Serialization;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationReplayTests
{
[Fact]
public void SameSeedAndActions_ProduceSameHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
TickActionBatch[] batches =
[
SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)),
SimulationTestFactory.CreateTick(2, new AimAxisChanged(new(1), 3, 4)),
SimulationTestFactory.CreateTick(3, new ButtonChanged(new(1), InputButton.Dash, true))
];
Simulation left = new(definition, config, 42);
Simulation right = new(definition, config, 42);
var leftHashes = ImmutableArray.CreateBuilder<int>();
var rightHashes = ImmutableArray.CreateBuilder<int>();
foreach (var batch in batches)
{
leftHashes.Add(left.Step(batch).StateHash);
rightHashes.Add(right.Step(batch).StateHash);
}
Assert.Equal(leftHashes.ToImmutable(), rightHashes.ToImmutable());
}
[Fact]
public void DifferentSeeds_ProduceDifferentHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
var batch = TickActionBatch.Empty(1);
Simulation left = new(definition, config, 1);
Simulation right = new(definition, config, 2);
Assert.NotEqual(left.Step(batch).StateHash, right.Step(batch).StateHash);
}
[Fact]
public void ReplayRecord_RoundTripsAndReplaysExpectedHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 9);
ReplayRecorder recorder = new();
TickActionBatch[] batches =
[
SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, 0), new AimAxisChanged(new(1), 5, 6)),
SimulationTestFactory.CreateTick(2, new ButtonChanged(new(1), InputButton.FireSecondary, true)),
SimulationTestFactory.CreateTick(3, new WeaponSlotSelected(new(1), 4))
];
foreach (var batch in batches)
{
var result = simulation.Step(batch);
recorder.Append(batch, result);
}
var replay = recorder.Build(definition, config, 9);
var payload = ReplayRecordSerializer.Serialize(replay);
var loadedReplay = ReplayRecordSerializer.Deserialize(payload);
var replayedHashes = ReplayPlayer.Play(loadedReplay, definition, config);
Assert.Equal(loadedReplay.Ticks.Select(static tick => tick.ExpectedStateHash).ToImmutableArray(), replayedHashes);
}
[Fact]
public void ReplayPlayer_RejectsMismatchedDefinition()
{
var config = SimulationTestFactory.CreateConfig();
var definition = SimulationTestFactory.CreateGameDefinition();
ReplayRecord replay = new(123, 9, config.TicksPerSecond, ImmutableArray<RecordedTick>.Empty);
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(replay, definition, config));
Assert.Contains("content hash", exception.Message.ToLowerInvariant());
}
[Fact]
public void ReplayPlayer_RejectsMismatchedTickRate()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var replay = new ReplayRecorder().Build(definition, SimulationTestFactory.CreateConfig(), 9) with { TicksPerSecond = 30 };
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(replay, definition, SimulationTestFactory.CreateConfig()));
Assert.Contains("tick rate", exception.Message.ToLowerInvariant());
}
[Fact]
public void ReplayPlayer_RejectsDivergentHashes()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 55);
ReplayRecorder recorder = new();
var batch = SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0));
var result = simulation.Step(batch);
recorder.Append(batch, result);
var replay = recorder.Build(definition, config, 55);
var divergentReplay = replay with { Ticks = ImmutableArray.Create(replay.Ticks[0] with { ExpectedStateHash = replay.Ticks[0].ExpectedStateHash + 1 }) };
var exception = Assert.Throws<InvalidOperationException>(() => ReplayPlayer.Play(divergentReplay, definition, config));
Assert.Contains("diverged", exception.Message.ToLowerInvariant());
}
}

View File

@@ -0,0 +1,45 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationSerializationTests
{
[Fact]
public void SaveStateLoadState_PreservesStateAndNextStepHash()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(11, 17), new(12, 18)), "TriggerActivated")), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10, true, FixPoint16.One, FixPoint16.One, new(3), 1, 1)));
var config = SimulationTestFactory.CreateConfig();
Simulation original = new(definition, config, 17);
original.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1), new ButtonChanged(new(1), InputButton.Jump, true)));
var bytes = original.SaveState();
var loaded = Simulation.LoadState(bytes, definition, config);
Assert.Equal(original.CurrentTick, loaded.CurrentTick);
Assert.Equal(original.CurrentSnapshot.StateHash, loaded.CurrentSnapshot.StateHash);
Assert.Equal(original.CurrentState.GetRequiredPlayer(new(1)).Health, loaded.CurrentState.GetRequiredPlayer(new(1)).Health);
Assert.Equal(original.CurrentState.ActivatedTriggerIds, loaded.CurrentState.ActivatedTriggerIds);
Assert.Equal(original.CurrentState.GetRequiredPlayer(new(1)).VerticalVelocity, loaded.CurrentState.GetRequiredPlayer(new(1)).VerticalVelocity);
Assert.Equal(original.CurrentState.GetRequiredPlayer(new(1)).IsGrounded, loaded.CurrentState.GetRequiredPlayer(new(1)).IsGrounded);
var nextBatch = SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0), new ButtonChanged(new(1), InputButton.FirePrimary, true));
var originalHash = original.Step(nextBatch).StateHash;
var loadedHash = loaded.Step(nextBatch).StateHash;
Assert.Equal(originalHash, loadedHash);
}
[Fact]
public void Constructor_NormalizesZeroSeed()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 0);
simulation.Step(TickActionBatch.Empty(1));
Assert.NotEqual(0UL, simulation.CurrentState.RandomState);
}
}

View File

@@ -0,0 +1,91 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Runtime;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationStateTests
{
[Fact]
public void PlayerState_CloneAndButtonReleasePreserveIndependentState()
{
PlayerState player = new(new(1), new(3, 4), 1, 2, 5, 6, 7, 0, 9, FixPoint16.One, true, 3, 4);
player.SetButton(InputButton.Dash, true);
player.ApplyDamage(4);
player.BufferJump(6);
var clone = player.Clone();
player.SetButton(InputButton.Dash, false);
player.SetPosition(new(8, 9));
player.SetVerticalVelocity(new(5));
Assert.NotEqual(player.ButtonMask, clone.ButtonMask);
Assert.Equal(7, clone.SelectedWeaponSlot);
Assert.Equal(3, clone.Position.m_X.ToIntRound());
Assert.Equal(4, clone.Position.m_Y.ToIntRound());
Assert.Equal(5, clone.Health);
Assert.Equal(1, clone.VerticalVelocity.ToIntRound());
Assert.True(clone.IsGrounded);
Assert.Equal(3, clone.LastGroundedTick);
Assert.Equal(6, clone.BufferedJumpTick);
}
[Fact]
public void SimulationState_CloneCreatesDeepCopy()
{
SimulationState original = new(4, 9, 123UL, 456UL, ImmutableArray.Create(new PlayerState(new(1), new(1, 2), 3, 4, 5, 6, 7, 8, 9, new(2), true, 4, -1)), ImmutableHashSet<string>.Empty.Add("checkpoint_a"));
var clone = original.Clone();
original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9);
original.ActivateTrigger("checkpoint_b");
Assert.Equal(4, clone.Tick);
Assert.Equal(9, clone.Seed);
Assert.Equal((ulong)123, clone.RandomState);
Assert.Equal((ulong)456, clone.LastRandomValue);
Assert.Equal(3, clone.GetRequiredPlayer(new(1)).MoveAxisX);
Assert.Equal(2, clone.GetRequiredPlayer(new(1)).VerticalVelocity.ToIntRound());
Assert.DoesNotContain("checkpoint_b", clone.ActivatedTriggerIds);
}
[Fact]
public void SimulationState_AcceptsDefaultPlayerArray()
{
SimulationState state = new(0, 1, 1UL, 0UL, default, ImmutableHashSet<string>.Empty);
Assert.Empty(state.Players);
Assert.Empty(state.ActivatedTriggerIds);
}
[Fact]
public void SimulationState_AcceptsDefaultTriggerSet()
{
SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray<PlayerState>.Empty, default!);
Assert.Empty(state.ActivatedTriggerIds);
}
[Fact]
public void ActivateTrigger_ReturnsFalseWhenRepeated()
{
SimulationState state = new(0, 1, 1UL, 0UL, ImmutableArray<PlayerState>.Empty, ImmutableHashSet<string>.Empty);
Assert.True(state.ActivateTrigger("checkpoint_a"));
Assert.False(state.ActivateTrigger("checkpoint_a"));
}
[Fact]
public void PlayerState_BufferedJumpExpiresOutsideWindow()
{
PlayerState player = new(new(1), new(0, 0), 0, 0, 0, 0, 0, 0, 10, FixPoint16.Zero, false, -1, -1);
player.BufferJump(3);
Assert.True(player.HasBufferedJump(4, 1));
Assert.False(player.HasBufferedJump(5, 1));
player.ConsumeBufferedJump();
Assert.False(player.HasBufferedJump(5, 5));
}
}

View File

@@ -0,0 +1,417 @@
using System.Collections.Immutable;
using System.Text;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationStepTests
{
private sealed record UnsupportedAction : SimulationAction;
private static byte[] CreateStatePayload(int playerId, int positionX, int positionY, bool isGrounded, int lastGroundedTick = -1)
{
return Encoding.UTF8.GetBytes($$"""
{"Version":{{SimulationDefaults.StateFormatVersion}},"Tick":0,"Seed":7,"RandomState":7,"LastRandomValue":0,"Players":[{"PlayerId":{{playerId}},"PositionX":{{positionX * 65536}},"PositionY":{{positionY * 65536}},"MoveAxisX":0,"MoveAxisY":0,"AimAxisX":0,"AimAxisY":0,"SelectedWeaponSlot":0,"ButtonMask":0,"Health":10,"VerticalVelocity":0,"IsGrounded":{{isGrounded.ToString().ToLowerInvariant()}},"LastGroundedTick":{{lastGroundedTick}},"BufferedJumpTick":-1}],"ActivatedTriggerIds":[]}
""");
}
[Fact]
public void Step_AdvancesTickSnapshotsAndMovement()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, -1), new AimAxisChanged(new(1), 30, 40), new ButtonChanged(new(1), InputButton.Jump, true), new WeaponSlotSelected(new(1), 3)));
Assert.Equal(1, simulation.CurrentTick);
Assert.Equal(0, simulation.PreviousSnapshot.Tick);
Assert.Equal(0, result.PreviousSnapshot.Tick);
Assert.Equal(1, result.CurrentSnapshot.Tick);
Assert.Single(result.Events);
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(12, player.Position.m_X.ToIntRound());
Assert.Equal(19, player.Position.m_Y.ToIntRound());
Assert.Equal(30, player.AimAxisX);
Assert.Equal(40, player.AimAxisY);
Assert.Equal(3, player.SelectedWeaponSlot);
Assert.Equal(10, player.Health);
Assert.NotEqual(0, player.ButtonMask);
Assert.Equal(result.StateHash, simulation.CurrentSnapshot.StateHash);
Assert.NotEqual(0UL, simulation.CurrentSnapshot.LastRandomValue);
}
[Fact]
public void Step_ClampsPlayerToWorldBounds()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(11, 20))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 5, 0)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(11, player.Position.m_X.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_AppliesHazardDamage()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(hazards: ImmutableArray.Create(new HazardDefinition("lava", new(new(11, 20), new(12, 21)), 3))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(7, player.Health);
Assert.Contains(result.Events, static e => e.Kind == "PlayerDamaged");
}
[Fact]
public void Step_IgnoresHazardsWhenPlayerIsOutside()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(hazards: ImmutableArray.Create(new HazardDefinition("lava", new(new(50, 50), new(60, 60)), 3))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(10, simulation.CurrentState.GetRequiredPlayer(new(1)).Health);
Assert.DoesNotContain(result.Events, static e => e.Kind == "PlayerDamaged");
}
[Fact]
public void Step_ActivatesTriggerOnlyOnce()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(11, 20), new(12, 21)), "TriggerActivated"))), SimulationTestFactory.CreateConfig(), 7);
var first = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
var second = simulation.Step(SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0)));
Assert.Contains(first.Events, static e => e.Kind == "TriggerActivated");
Assert.DoesNotContain(second.Events, static e => e.Kind == "TriggerActivated");
Assert.Contains("checkpoint_a", simulation.CurrentState.ActivatedTriggerIds);
}
[Fact]
public void Step_IgnoresTriggersWhenPlayerIsOutside()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(50, 50), new(60, 60)), "TriggerActivated"))), SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.DoesNotContain(result.Events, static e => e.Kind == "TriggerActivated");
Assert.Empty(simulation.CurrentState.ActivatedTriggerIds);
}
[Fact]
public void Step_RejectsUnexpectedTickNumbers()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(TickActionBatch.Empty(2)));
Assert.Contains("Expected tick 1", exception.Message);
}
[Fact]
public void Step_RejectsUnknownPlayers()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(99), 1, 0))));
Assert.Contains("Unknown player id 99", exception.Message);
}
[Fact]
public void Step_RejectsUnsupportedActions()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(), 7);
var exception = Assert.Throws<NotSupportedException>(() => simulation.Step(SimulationTestFactory.CreateTick(1, new UnsupportedAction())));
Assert.Contains("Unsupported action type", exception.Message);
}
[Fact]
public void Constructor_RejectsDuplicatePlayers()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new(new(1), new(0, 0), 10), new PlayerDefinition(new(1), new(2, 3), 10)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("Duplicate player id 1", exception.Message);
}
[Fact]
public void Constructor_RejectsSpawnOutsideBounds()
{
GameDefinition definition = new(new(new(new(0, 0), new(5, 5)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("spawn must start inside world bounds", exception.Message);
}
[Fact]
public void Constructor_RejectsNonPositiveHealth()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 0)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("positive health", exception.Message);
}
[Fact]
public void Constructor_RejectsNullDefinition()
{
var exception = Assert.Throws<ArgumentNullException>(() => new Simulation(null!, SimulationTestFactory.CreateConfig(), 1));
Assert.Equal("gameDefinition", exception.ParamName);
}
[Fact]
public void Constructor_RejectsNullConfig()
{
var exception = Assert.Throws<ArgumentNullException>(() => new Simulation(SimulationTestFactory.CreateGameDefinition(), null!, 1));
Assert.Equal("config", exception.ParamName);
}
[Fact]
public void LoadState_RejectsNullDefinition()
{
var definition = SimulationTestFactory.CreateGameDefinition();
var config = SimulationTestFactory.CreateConfig();
Simulation simulation = new(definition, config, 2);
var bytes = simulation.SaveState();
var exception = Assert.Throws<ArgumentNullException>(() => Simulation.LoadState(bytes, null!, config));
Assert.Equal("gameDefinition", exception.ParamName);
}
[Fact]
public void Step_PlatformerJumpUsesBufferedInputAndProducesVerticalVelocity()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1), new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.False(player.IsGrounded);
Assert.Equal(12, player.Position.m_X.ToIntRound());
Assert.Equal(18, player.Position.m_Y.ToIntRound());
Assert.Equal(-2, player.VerticalVelocity.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerJumped");
Assert.DoesNotContain(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_PlatformerUsesCoyoteTimeAfterLeavingPlatform()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(10, 20), new(12, 22))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var first = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 2, 0)));
var second = simulation.Step(SimulationTestFactory.CreateTick(2, new MoveAxisChanged(new(1), 0, 0), new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.DoesNotContain(first.Events, static e => e.Kind == "PlayerJumped");
Assert.Contains(second.Events, static e => e.Kind == "PlayerJumped");
Assert.False(player.IsGrounded);
Assert.Equal(14, player.Position.m_X.ToIntRound());
Assert.Equal(18, player.Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerConsumesJumpBufferOnLanding()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 20)), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 18), 10, true, FixPoint16.One, new(2), new(3), 0, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.False(player.IsGrounded);
Assert.Equal(17, player.Position.m_Y.ToIntRound());
Assert.Equal(-3, player.VerticalVelocity.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerLanded");
Assert.Contains(result.Events, static e => e.Kind == "PlayerJumped");
}
[Fact]
public void Step_PlatformerClampsHorizontalMovementAtWorldEdge()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(11, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(11, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_X.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_PlatformerClampsJumpAtWorldCeiling()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 2)), players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 2), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(4), 0, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true)));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(0, player.Position.m_Y.ToIntRound());
Assert.Equal(0, player.VerticalVelocity.ToIntRound());
Assert.Contains(result.Events, static e => e.Kind == "PlayerClamped");
}
[Fact]
public void Step_PlatformerLandsOnHighestCrossedPlatform()
{
var platforms = ImmutableArray.Create(new("lower", new(new(8, 20), new(12, 22))), new SolidPlatformDefinition("upper", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
var result = simulation.Step(SimulationTestFactory.CreateTick(1));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(15, player.Position.m_Y.ToIntRound());
Assert.True(player.IsGrounded);
Assert.Contains(result.Events, static e => e.Kind == "PlayerLanded");
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenStillAboveTop()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 10), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(10, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenAlreadyBelowTop()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10, true, FixPoint16.Zero, FixPoint16.Zero, new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(20, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenFallingOutsideRightEdge()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(14, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(22, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_PlatformerIgnoresPlatformWhenFallingOutsideLeftEdge()
{
var platforms = ImmutableArray.Create(new SolidPlatformDefinition("ledge", new(new(8, 15), new(12, 17))));
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(50, 40)), platforms: platforms, players: ImmutableArray.Create(new PlayerDefinition(new(1), new(6, 10), 10, true, FixPoint16.Zero, new(12), new(3), 1, 1)));
Simulation simulation = new(definition, SimulationTestFactory.CreateConfig(), 7);
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.Equal(22, simulation.CurrentState.GetRequiredPlayer(new(1)).Position.m_Y.ToIntRound());
}
[Fact]
public void Step_LoadedPlatformerStateCanRegainGroundedFlagFromSupport()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
var simulation = Simulation.LoadState(CreateStatePayload(1, 10, 20, false), definition, SimulationTestFactory.CreateConfig());
simulation.Step(SimulationTestFactory.CreateTick(1));
Assert.True(simulation.CurrentState.GetRequiredPlayer(new(1)).IsGrounded);
}
[Fact]
public void Step_LoadedStateBelowFloorClampsBackIntoSupport()
{
var definition = SimulationTestFactory.CreateGameDefinition(new(new(0, 0), new(20, 20)), players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
var simulation = Simulation.LoadState(CreateStatePayload(1, 10, 25, false), definition, SimulationTestFactory.CreateConfig());
simulation.Step(SimulationTestFactory.CreateTick(1));
var player = simulation.CurrentState.GetRequiredPlayer(new(1));
Assert.Equal(20, player.Position.m_Y.ToIntRound());
Assert.False(player.IsGrounded);
}
[Fact]
public void Step_LoadedStateRejectsPlayersMissingFromDefinition()
{
var definition = SimulationTestFactory.CreateGameDefinition(players: ImmutableArray.Create(SimulationTestFactory.CreatePlatformerPlayerDefinition(new(1), 10, 20)));
var simulation = Simulation.LoadState(CreateStatePayload(99, 10, 20, true, 0), definition, SimulationTestFactory.CreateConfig());
var exception = Assert.Throws<InvalidOperationException>(() => simulation.Step(SimulationTestFactory.CreateTick(1)));
Assert.Contains("Unknown player id 99", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeJumpBufferTicks()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, new(3), 1, -1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("jump buffer ticks", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeMoveSpeed()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, -FixPoint16.One, FixPoint16.One, new(3), 1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("move speed", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeGravity()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, -FixPoint16.One, new(3), 1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("gravity", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeJumpVelocity()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, -FixPoint16.One, 1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("jump velocity", exception.Message);
}
[Fact]
public void Constructor_RejectsNegativeCoyoteTicks()
{
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty, ImmutableArray<SolidPlatformDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 10, true, FixPoint16.One, FixPoint16.One, new(3), -1, 1)));
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
Assert.Contains("coyote ticks", exception.Message);
}
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using SideScrollerGame.Sim.Definitions;
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Math;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
internal static class SimulationTestFactory
{
public static GameDefinition CreateGameDefinition(AxisAlignedBounds? worldBounds = null, ImmutableArray<HazardDefinition> hazards = default, ImmutableArray<TriggerDefinition> triggers = default, ImmutableArray<SolidPlatformDefinition> platforms = default, ImmutableArray<PlayerDefinition> players = default)
{
return new(new(worldBounds ?? new(new(0, 0), new(100, 100)), hazards, triggers, platforms), players.IsDefault ? ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)) : players);
}
public static PlayerDefinition CreatePlatformerPlayerDefinition(PlayerId playerId, int spawnX, int spawnY)
{
return new(playerId, new(spawnX, spawnY), 10, true, new(2), FixPoint16.One, new(3), 1, 1);
}
public static SimulationConfig CreateConfig(VerificationMode verificationMode = VerificationMode.None)
{
return new(SimulationDefaults.DefaultTicksPerSecond, verificationMode);
}
public static TickActionBatch CreateTick(int tick, params SimulationAction[] actions)
{
return new(tick, ImmutableArray.Create(actions));
}
}

View File

@@ -0,0 +1,27 @@
using SideScrollerGame.Sim.Input;
using SideScrollerGame.Sim.Verification;
namespace SideScrollerGame.Sim.Tests;
public sealed class SimulationVerificationTests
{
[Fact]
public void RoundTripStateMode_AllowsStepping()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(VerificationMode.RoundTripState), 22);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 0)));
Assert.Equal(1, result.CurrentSnapshot.Tick);
}
[Fact]
public void RoundTripAndStepCloneMode_AllowsStepping()
{
Simulation simulation = new(SimulationTestFactory.CreateGameDefinition(), SimulationTestFactory.CreateConfig(VerificationMode.RoundTripAndStepClone), 23);
var result = simulation.Step(SimulationTestFactory.CreateTick(1, new ButtonChanged(new(1), InputButton.Jump, true)));
Assert.Equal(1, result.CurrentSnapshot.Tick);
}
}