Add platformer locomotion slice
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
|||||||
bin
|
bin
|
||||||
obj
|
obj
|
||||||
coverage.cobertura.xml
|
coverage.cobertura.xml
|
||||||
|
coverage.json
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ The user-visible outcome is not merely “new projects were added.” The outcom
|
|||||||
- [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 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 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 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 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 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.
|
- [ ] Implement Godot host adapters for input translation, fixed-step execution, interpolation, presentation mapping, sound playback, music transitions, and debug transport controls.
|
||||||
@@ -44,6 +45,8 @@ The user-visible outcome is not merely “new projects were added.” The outcom
|
|||||||
Evidence: the simulation tests replay hashes successfully after round-tripping `ReplayRecordSerializer` and `Simulation.SaveState` payloads.
|
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.
|
- 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`.
|
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
|
## Decision Log
|
||||||
|
|
||||||
@@ -68,12 +71,15 @@ The user-visible outcome is not merely “new projects were added.” The outcom
|
|||||||
- Decision: start Milestone 4 with axis-aligned world bounds, hazards, and one-shot triggers before tackling richer collision geometry or combat systems.
|
- 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.
|
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
|
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
|
## 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
|
## Context and Orientation
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ public sealed record GameDefinition
|
|||||||
public LevelDefinition Level { get; init; }
|
public LevelDefinition Level { get; init; }
|
||||||
|
|
||||||
public ImmutableArray<PlayerDefinition> Players { get; init; }
|
public ImmutableArray<PlayerDefinition> Players { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,12 @@ namespace SideScrollerGame.Sim.Definitions;
|
|||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public sealed record LevelDefinition
|
public sealed record LevelDefinition
|
||||||
{
|
{
|
||||||
public LevelDefinition(AxisAlignedBounds worldBounds, ImmutableArray<HazardDefinition> hazards, ImmutableArray<TriggerDefinition> triggers)
|
public LevelDefinition(AxisAlignedBounds worldBounds, ImmutableArray<HazardDefinition> hazards, ImmutableArray<TriggerDefinition> triggers, ImmutableArray<SolidPlatformDefinition> platforms)
|
||||||
{
|
{
|
||||||
WorldBounds = worldBounds;
|
WorldBounds = worldBounds;
|
||||||
Hazards = hazards.IsDefault ? ImmutableArray<HazardDefinition>.Empty : hazards;
|
Hazards = hazards.IsDefault ? ImmutableArray<HazardDefinition>.Empty : hazards;
|
||||||
Triggers = triggers.IsDefault ? ImmutableArray<TriggerDefinition>.Empty : triggers;
|
Triggers = triggers.IsDefault ? ImmutableArray<TriggerDefinition>.Empty : triggers;
|
||||||
|
Platforms = platforms.IsDefault ? ImmutableArray<SolidPlatformDefinition>.Empty : platforms;
|
||||||
}
|
}
|
||||||
|
|
||||||
public AxisAlignedBounds WorldBounds { get; init; }
|
public AxisAlignedBounds WorldBounds { get; init; }
|
||||||
@@ -18,4 +19,6 @@ public sealed record LevelDefinition
|
|||||||
public ImmutableArray<HazardDefinition> Hazards { get; init; }
|
public ImmutableArray<HazardDefinition> Hazards { get; init; }
|
||||||
|
|
||||||
public ImmutableArray<TriggerDefinition> Triggers { get; init; }
|
public ImmutableArray<TriggerDefinition> Triggers { get; init; }
|
||||||
|
|
||||||
|
public ImmutableArray<SolidPlatformDefinition> Platforms { get; init; }
|
||||||
}
|
}
|
||||||
@@ -6,11 +6,17 @@ namespace SideScrollerGame.Sim.Definitions;
|
|||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public sealed record PlayerDefinition
|
public sealed record PlayerDefinition
|
||||||
{
|
{
|
||||||
public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition, int maxHealth)
|
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;
|
PlayerId = playerId;
|
||||||
SpawnPosition = spawnPosition;
|
SpawnPosition = spawnPosition;
|
||||||
MaxHealth = maxHealth;
|
MaxHealth = maxHealth;
|
||||||
|
UsesPlatformerMotion = usesPlatformerMotion;
|
||||||
|
MoveSpeedPerTick = moveSpeedPerTick;
|
||||||
|
GravityPerTick = gravityPerTick;
|
||||||
|
JumpVelocityPerTick = jumpVelocityPerTick;
|
||||||
|
CoyoteTicks = coyoteTicks;
|
||||||
|
JumpBufferTicks = jumpBufferTicks;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayerId PlayerId { get; init; }
|
public PlayerId PlayerId { get; init; }
|
||||||
@@ -18,4 +24,16 @@ public sealed record PlayerDefinition
|
|||||||
public FixPointVector2 SpawnPosition { get; init; }
|
public FixPointVector2 SpawnPosition { get; init; }
|
||||||
|
|
||||||
public int MaxHealth { 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; }
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -6,13 +6,15 @@ namespace SideScrollerGame.Sim.Runtime;
|
|||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
public sealed record PlayerSnapshot
|
public sealed record PlayerSnapshot
|
||||||
{
|
{
|
||||||
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, int health)
|
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, int health, FixPoint16 verticalVelocity, bool isGrounded)
|
||||||
{
|
{
|
||||||
PlayerId = playerId;
|
PlayerId = playerId;
|
||||||
Position = position;
|
Position = position;
|
||||||
MoveAxisX = moveAxisX;
|
MoveAxisX = moveAxisX;
|
||||||
MoveAxisY = moveAxisY;
|
MoveAxisY = moveAxisY;
|
||||||
Health = health;
|
Health = health;
|
||||||
|
VerticalVelocity = verticalVelocity;
|
||||||
|
IsGrounded = isGrounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayerId PlayerId { get; init; }
|
public PlayerId PlayerId { get; init; }
|
||||||
@@ -24,4 +26,8 @@ public sealed record PlayerSnapshot
|
|||||||
public sbyte MoveAxisY { get; init; }
|
public sbyte MoveAxisY { get; init; }
|
||||||
|
|
||||||
public int Health { get; init; }
|
public int Health { get; init; }
|
||||||
|
|
||||||
|
public FixPoint16 VerticalVelocity { get; init; }
|
||||||
|
|
||||||
|
public bool IsGrounded { get; init; }
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,7 @@ namespace SideScrollerGame.Sim.Runtime;
|
|||||||
|
|
||||||
public sealed class PlayerState
|
public sealed class PlayerState
|
||||||
{
|
{
|
||||||
public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask, int health)
|
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;
|
PlayerId = playerId;
|
||||||
Position = position;
|
Position = position;
|
||||||
@@ -16,11 +16,15 @@ public sealed class PlayerState
|
|||||||
SelectedWeaponSlot = selectedWeaponSlot;
|
SelectedWeaponSlot = selectedWeaponSlot;
|
||||||
ButtonMask = buttonMask;
|
ButtonMask = buttonMask;
|
||||||
Health = health;
|
Health = health;
|
||||||
|
VerticalVelocity = verticalVelocity;
|
||||||
|
IsGrounded = isGrounded;
|
||||||
|
LastGroundedTick = lastGroundedTick;
|
||||||
|
BufferedJumpTick = bufferedJumpTick;
|
||||||
}
|
}
|
||||||
|
|
||||||
public PlayerState Clone()
|
public PlayerState Clone()
|
||||||
{
|
{
|
||||||
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask, Health);
|
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask, Health, VerticalVelocity, IsGrounded, LastGroundedTick, BufferedJumpTick);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void SetMoveAxis(sbyte x, sbyte y)
|
public void SetMoveAxis(sbyte x, sbyte y)
|
||||||
@@ -46,6 +50,11 @@ public sealed class PlayerState
|
|||||||
SelectedWeaponSlot = slotIndex;
|
SelectedWeaponSlot = slotIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsButtonPressed(InputButton button)
|
||||||
|
{
|
||||||
|
return (ButtonMask & (1 << (int)button)) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
public void Advance()
|
public void Advance()
|
||||||
{
|
{
|
||||||
Position += new FixPointVector2(MoveAxisX, MoveAxisY);
|
Position += new FixPointVector2(MoveAxisX, MoveAxisY);
|
||||||
@@ -56,11 +65,46 @@ public sealed class PlayerState
|
|||||||
Health = System.Math.Max(0, Health - 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)
|
public void SetPosition(FixPointVector2 position)
|
||||||
{
|
{
|
||||||
Position = 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 PlayerId PlayerId { get; }
|
||||||
|
|
||||||
public FixPointVector2 Position { get; private set; }
|
public FixPointVector2 Position { get; private set; }
|
||||||
@@ -78,4 +122,12 @@ public sealed class PlayerState
|
|||||||
public int ButtonMask { get; private set; }
|
public int ButtonMask { get; private set; }
|
||||||
|
|
||||||
public int Health { 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; }
|
||||||
}
|
}
|
||||||
@@ -26,6 +26,18 @@ internal static class GameDefinitionHasher
|
|||||||
public int SpawnY { get; init; }
|
public int SpawnY { get; init; }
|
||||||
|
|
||||||
public int MaxHealth { 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]
|
[ExcludeFromCodeCoverage]
|
||||||
@@ -36,6 +48,8 @@ internal static class GameDefinitionHasher
|
|||||||
public ImmutableArray<HazardDefinitionDocument> Hazards { get; init; }
|
public ImmutableArray<HazardDefinitionDocument> Hazards { get; init; }
|
||||||
|
|
||||||
public ImmutableArray<TriggerDefinitionDocument> Triggers { get; init; }
|
public ImmutableArray<TriggerDefinitionDocument> Triggers { get; init; }
|
||||||
|
|
||||||
|
public ImmutableArray<SolidPlatformDefinitionDocument> Platforms { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
[ExcludeFromCodeCoverage]
|
[ExcludeFromCodeCoverage]
|
||||||
@@ -70,6 +84,14 @@ internal static class GameDefinitionHasher
|
|||||||
public string Kind { get; init; } = string.Empty;
|
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)
|
public static int Compute(GameDefinition gameDefinition)
|
||||||
{
|
{
|
||||||
List<PlayerDefinitionDocument> players = new(gameDefinition.Players.Length);
|
List<PlayerDefinitionDocument> players = new(gameDefinition.Players.Length);
|
||||||
@@ -80,7 +102,13 @@ internal static class GameDefinitionHasher
|
|||||||
PlayerId = player.PlayerId.Value,
|
PlayerId = player.PlayerId.Value,
|
||||||
SpawnX = player.SpawnPosition.m_X.m_Value,
|
SpawnX = player.SpawnPosition.m_X.m_Value,
|
||||||
SpawnY = player.SpawnPosition.m_Y.m_Value,
|
SpawnY = player.SpawnPosition.m_Y.m_Value,
|
||||||
MaxHealth = player.MaxHealth
|
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
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,13 +134,24 @@ internal static class GameDefinitionHasher
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument
|
||||||
{
|
{
|
||||||
Level = new()
|
Level = new()
|
||||||
{
|
{
|
||||||
WorldBounds = ToDocument(gameDefinition.Level.WorldBounds),
|
WorldBounds = ToDocument(gameDefinition.Level.WorldBounds),
|
||||||
Hazards = hazards.ToImmutableArray(),
|
Hazards = hazards.ToImmutableArray(),
|
||||||
Triggers = triggers.ToImmutableArray()
|
Triggers = triggers.ToImmutableArray(),
|
||||||
|
Platforms = platforms.ToImmutableArray()
|
||||||
},
|
},
|
||||||
Players = players.ToImmutableArray()
|
Players = players.ToImmutableArray()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ internal static class SimulationStateSerializer
|
|||||||
public int ButtonMask { get; init; }
|
public int ButtonMask { get; init; }
|
||||||
|
|
||||||
public int Health { 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)
|
public static byte[] Serialize(SimulationState state)
|
||||||
@@ -67,7 +75,11 @@ internal static class SimulationStateSerializer
|
|||||||
AimAxisY = player.AimAxisY,
|
AimAxisY = player.AimAxisY,
|
||||||
SelectedWeaponSlot = player.SelectedWeaponSlot,
|
SelectedWeaponSlot = player.SelectedWeaponSlot,
|
||||||
ButtonMask = player.ButtonMask,
|
ButtonMask = player.ButtonMask,
|
||||||
Health = player.Health
|
Health = player.Health,
|
||||||
|
VerticalVelocity = player.VerticalVelocity.m_Value,
|
||||||
|
IsGrounded = player.IsGrounded,
|
||||||
|
LastGroundedTick = player.LastGroundedTick,
|
||||||
|
BufferedJumpTick = player.BufferedJumpTick
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +103,7 @@ internal static class SimulationStateSerializer
|
|||||||
|
|
||||||
var players = ImmutableArray.CreateBuilder<PlayerState>(document.Players.Length);
|
var players = ImmutableArray.CreateBuilder<PlayerState>(document.Players.Length);
|
||||||
foreach (var player in document.Players)
|
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));
|
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));
|
return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable(), document.ActivatedTriggerIds.ToImmutableHashSet(StringComparer.Ordinal));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public sealed class Simulation
|
|||||||
PreviousSnapshot = CurrentSnapshot;
|
PreviousSnapshot = CurrentSnapshot;
|
||||||
List<SimulationEvent> events = new();
|
List<SimulationEvent> events = new();
|
||||||
|
|
||||||
ApplyActions(actions);
|
ApplyActions(actions.Tick, actions);
|
||||||
var nextRandomState = AdvanceRandom();
|
var nextRandomState = AdvanceRandom();
|
||||||
AdvancePlayers(actions.Tick, events);
|
AdvancePlayers(actions.Tick, events);
|
||||||
ResolveBounds(actions.Tick, events);
|
ResolveBounds(actions.Tick, events);
|
||||||
@@ -84,7 +84,10 @@ public sealed class Simulation
|
|||||||
|
|
||||||
var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length);
|
var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length);
|
||||||
foreach (var player in gameDefinition.Players)
|
foreach (var player in gameDefinition.Players)
|
||||||
players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0, player.MaxHealth));
|
{
|
||||||
|
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);
|
var normalizedSeed = NormalizeSeed(seed);
|
||||||
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable(), ImmutableHashSet<string>.Empty);
|
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable(), ImmutableHashSet<string>.Empty);
|
||||||
@@ -108,6 +111,21 @@ public sealed class Simulation
|
|||||||
|
|
||||||
if (!gameDefinition.Level.WorldBounds.Contains(player.SpawnPosition))
|
if (!gameDefinition.Level.WorldBounds.Contains(player.SpawnPosition))
|
||||||
throw new InvalidOperationException($"Player {player.PlayerId.Value} spawn must start inside world bounds.");
|
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.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,12 +138,12 @@ public sealed class Simulation
|
|||||||
{
|
{
|
||||||
var players = ImmutableArray.CreateBuilder<PlayerSnapshot>(state.Players.Length);
|
var players = ImmutableArray.CreateBuilder<PlayerSnapshot>(state.Players.Length);
|
||||||
foreach (var player in state.Players)
|
foreach (var player in state.Players)
|
||||||
players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY, player.Health));
|
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());
|
return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ApplyActions(TickActionBatch actions)
|
private void ApplyActions(int tick, TickActionBatch actions)
|
||||||
{
|
{
|
||||||
foreach (var action in actions.Actions)
|
foreach (var action in actions.Actions)
|
||||||
{
|
{
|
||||||
@@ -138,7 +156,11 @@ public sealed class Simulation
|
|||||||
CurrentState.GetRequiredPlayer(aimAxisChanged.PlayerId).SetAimAxis(aimAxisChanged.X, aimAxisChanged.Y);
|
CurrentState.GetRequiredPlayer(aimAxisChanged.PlayerId).SetAimAxis(aimAxisChanged.X, aimAxisChanged.Y);
|
||||||
break;
|
break;
|
||||||
case ButtonChanged buttonChanged:
|
case ButtonChanged buttonChanged:
|
||||||
CurrentState.GetRequiredPlayer(buttonChanged.PlayerId).SetButton(buttonChanged.Button, buttonChanged.IsPressed);
|
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;
|
break;
|
||||||
case WeaponSlotSelected weaponSlotSelected:
|
case WeaponSlotSelected weaponSlotSelected:
|
||||||
CurrentState.GetRequiredPlayer(weaponSlotSelected.PlayerId).SelectWeaponSlot(weaponSlotSelected.SlotIndex);
|
CurrentState.GetRequiredPlayer(weaponSlotSelected.PlayerId).SelectWeaponSlot(weaponSlotSelected.SlotIndex);
|
||||||
@@ -153,7 +175,10 @@ public sealed class Simulation
|
|||||||
{
|
{
|
||||||
foreach (var player in CurrentState.Players)
|
foreach (var player in CurrentState.Players)
|
||||||
{
|
{
|
||||||
if (player.MoveAxisX != 0 || player.MoveAxisY != 0)
|
var definition = GetPlayerDefinition(player.PlayerId);
|
||||||
|
if (definition.UsesPlatformerMotion)
|
||||||
|
AdvancePlatformerPlayer(player, definition, tick, events);
|
||||||
|
else if (player.MoveAxisX != 0 || player.MoveAxisY != 0)
|
||||||
{
|
{
|
||||||
player.Advance();
|
player.Advance();
|
||||||
events.Add(new("PlayerMoved", tick, player.PlayerId));
|
events.Add(new("PlayerMoved", tick, player.PlayerId));
|
||||||
@@ -165,6 +190,9 @@ public sealed class Simulation
|
|||||||
{
|
{
|
||||||
foreach (var player in CurrentState.Players)
|
foreach (var player in CurrentState.Players)
|
||||||
{
|
{
|
||||||
|
if (GetPlayerDefinition(player.PlayerId).UsesPlatformerMotion)
|
||||||
|
continue;
|
||||||
|
|
||||||
var clamped = m_GameDefinition.Level.WorldBounds.Clamp(player.Position);
|
var clamped = m_GameDefinition.Level.WorldBounds.Clamp(player.Position);
|
||||||
if (clamped != player.Position)
|
if (clamped != player.Position)
|
||||||
{
|
{
|
||||||
@@ -174,6 +202,135 @@ public sealed class Simulation
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
private void ResolveHazards(int tick, List<SimulationEvent> events)
|
||||||
{
|
{
|
||||||
foreach (var player in CurrentState.Players)
|
foreach (var player in CurrentState.Players)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using SideScrollerGame.Sim.Definitions;
|
using SideScrollerGame.Sim.Definitions;
|
||||||
using SideScrollerGame.Sim.Input;
|
using SideScrollerGame.Sim.Input;
|
||||||
|
using SideScrollerGame.Sim.Math;
|
||||||
|
|
||||||
namespace SideScrollerGame.Sim.Tests;
|
namespace SideScrollerGame.Sim.Tests;
|
||||||
|
|
||||||
@@ -9,11 +10,11 @@ public sealed class SimulationSerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SaveStateLoadState_PreservesStateAndNextStepHash()
|
public void SaveStateLoadState_PreservesStateAndNextStepHash()
|
||||||
{
|
{
|
||||||
var definition = SimulationTestFactory.CreateGameDefinition(triggers: ImmutableArray.Create(new TriggerDefinition("checkpoint_a", new(new(11, 21), new(12, 22)), "TriggerActivated")));
|
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();
|
var config = SimulationTestFactory.CreateConfig();
|
||||||
|
|
||||||
Simulation original = new(definition, config, 17);
|
Simulation original = new(definition, config, 17);
|
||||||
original.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1)));
|
original.Step(SimulationTestFactory.CreateTick(1, new MoveAxisChanged(new(1), 1, 1), new ButtonChanged(new(1), InputButton.Jump, true)));
|
||||||
|
|
||||||
var bytes = original.SaveState();
|
var bytes = original.SaveState();
|
||||||
var loaded = Simulation.LoadState(bytes, definition, config);
|
var loaded = Simulation.LoadState(bytes, definition, config);
|
||||||
@@ -22,8 +23,10 @@ public sealed class SimulationSerializationTests
|
|||||||
Assert.Equal(original.CurrentSnapshot.StateHash, loaded.CurrentSnapshot.StateHash);
|
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.GetRequiredPlayer(new(1)).Health, loaded.CurrentState.GetRequiredPlayer(new(1)).Health);
|
||||||
Assert.Equal(original.CurrentState.ActivatedTriggerIds, loaded.CurrentState.ActivatedTriggerIds);
|
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 ButtonChanged(new(1), InputButton.FirePrimary, true));
|
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 originalHash = original.Step(nextBatch).StateHash;
|
||||||
var loadedHash = loaded.Step(nextBatch).StateHash;
|
var loadedHash = loaded.Step(nextBatch).StateHash;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using SideScrollerGame.Sim.Input;
|
using SideScrollerGame.Sim.Input;
|
||||||
|
using SideScrollerGame.Sim.Math;
|
||||||
using SideScrollerGame.Sim.Runtime;
|
using SideScrollerGame.Sim.Runtime;
|
||||||
|
|
||||||
namespace SideScrollerGame.Sim.Tests;
|
namespace SideScrollerGame.Sim.Tests;
|
||||||
@@ -9,25 +10,31 @@ public sealed class SimulationStateTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void PlayerState_CloneAndButtonReleasePreserveIndependentState()
|
public void PlayerState_CloneAndButtonReleasePreserveIndependentState()
|
||||||
{
|
{
|
||||||
PlayerState player = new(new(1), new(3, 4), 1, 2, 5, 6, 7, 0, 9);
|
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.SetButton(InputButton.Dash, true);
|
||||||
player.ApplyDamage(4);
|
player.ApplyDamage(4);
|
||||||
|
player.BufferJump(6);
|
||||||
|
|
||||||
var clone = player.Clone();
|
var clone = player.Clone();
|
||||||
player.SetButton(InputButton.Dash, false);
|
player.SetButton(InputButton.Dash, false);
|
||||||
player.SetPosition(new(8, 9));
|
player.SetPosition(new(8, 9));
|
||||||
|
player.SetVerticalVelocity(new(5));
|
||||||
|
|
||||||
Assert.NotEqual(player.ButtonMask, clone.ButtonMask);
|
Assert.NotEqual(player.ButtonMask, clone.ButtonMask);
|
||||||
Assert.Equal(7, clone.SelectedWeaponSlot);
|
Assert.Equal(7, clone.SelectedWeaponSlot);
|
||||||
Assert.Equal(3, clone.Position.m_X.ToIntRound());
|
Assert.Equal(3, clone.Position.m_X.ToIntRound());
|
||||||
Assert.Equal(4, clone.Position.m_Y.ToIntRound());
|
Assert.Equal(4, clone.Position.m_Y.ToIntRound());
|
||||||
Assert.Equal(5, clone.Health);
|
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]
|
[Fact]
|
||||||
public void SimulationState_CloneCreatesDeepCopy()
|
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)), ImmutableHashSet<string>.Empty.Add("checkpoint_a"));
|
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();
|
var clone = original.Clone();
|
||||||
|
|
||||||
original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9);
|
original.GetRequiredPlayer(new(1)).SetMoveAxis(9, 9);
|
||||||
@@ -38,6 +45,7 @@ public sealed class SimulationStateTests
|
|||||||
Assert.Equal((ulong)123, clone.RandomState);
|
Assert.Equal((ulong)123, clone.RandomState);
|
||||||
Assert.Equal((ulong)456, clone.LastRandomValue);
|
Assert.Equal((ulong)456, clone.LastRandomValue);
|
||||||
Assert.Equal(3, clone.GetRequiredPlayer(new(1)).MoveAxisX);
|
Assert.Equal(3, clone.GetRequiredPlayer(new(1)).MoveAxisX);
|
||||||
|
Assert.Equal(2, clone.GetRequiredPlayer(new(1)).VerticalVelocity.ToIntRound());
|
||||||
Assert.DoesNotContain("checkpoint_b", clone.ActivatedTriggerIds);
|
Assert.DoesNotContain("checkpoint_b", clone.ActivatedTriggerIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,4 +74,18 @@ public sealed class SimulationStateTests
|
|||||||
Assert.True(state.ActivateTrigger("checkpoint_a"));
|
Assert.True(state.ActivateTrigger("checkpoint_a"));
|
||||||
Assert.False(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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
|
using System.Text;
|
||||||
using SideScrollerGame.Sim.Definitions;
|
using SideScrollerGame.Sim.Definitions;
|
||||||
using SideScrollerGame.Sim.Input;
|
using SideScrollerGame.Sim.Input;
|
||||||
|
using SideScrollerGame.Sim.Math;
|
||||||
|
|
||||||
namespace SideScrollerGame.Sim.Tests;
|
namespace SideScrollerGame.Sim.Tests;
|
||||||
|
|
||||||
@@ -8,6 +10,13 @@ public sealed class SimulationStepTests
|
|||||||
{
|
{
|
||||||
private sealed record UnsupportedAction : SimulationAction;
|
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]
|
[Fact]
|
||||||
public void Step_AdvancesTickSnapshotsAndMovement()
|
public void Step_AdvancesTickSnapshotsAndMovement()
|
||||||
{
|
{
|
||||||
@@ -125,7 +134,7 @@ public sealed class SimulationStepTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_RejectsDuplicatePlayers()
|
public void Constructor_RejectsDuplicatePlayers()
|
||||||
{
|
{
|
||||||
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty), ImmutableArray.Create(new(new(1), new(0, 0), 10), new PlayerDefinition(new(1), new(2, 3), 10)));
|
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));
|
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
|
||||||
|
|
||||||
@@ -135,7 +144,7 @@ public sealed class SimulationStepTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_RejectsSpawnOutsideBounds()
|
public void Constructor_RejectsSpawnOutsideBounds()
|
||||||
{
|
{
|
||||||
GameDefinition definition = new(new(new(new(0, 0), new(5, 5)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)));
|
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));
|
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
|
||||||
|
|
||||||
@@ -145,7 +154,7 @@ public sealed class SimulationStepTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Constructor_RejectsNonPositiveHealth()
|
public void Constructor_RejectsNonPositiveHealth()
|
||||||
{
|
{
|
||||||
GameDefinition definition = new(new(new(new(0, 0), new(100, 100)), ImmutableArray<HazardDefinition>.Empty, ImmutableArray<TriggerDefinition>.Empty), ImmutableArray.Create(new PlayerDefinition(new(1), new(1, 1), 0)));
|
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));
|
var exception = Assert.Throws<InvalidOperationException>(() => new Simulation(definition, SimulationTestFactory.CreateConfig(), 11));
|
||||||
|
|
||||||
@@ -180,4 +189,229 @@ public sealed class SimulationStepTests
|
|||||||
|
|
||||||
Assert.Equal("gameDefinition", exception.ParamName);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
using System.Collections.Immutable;
|
using System.Collections.Immutable;
|
||||||
using SideScrollerGame.Sim.Definitions;
|
using SideScrollerGame.Sim.Definitions;
|
||||||
using SideScrollerGame.Sim.Input;
|
using SideScrollerGame.Sim.Input;
|
||||||
|
using SideScrollerGame.Sim.Math;
|
||||||
using SideScrollerGame.Sim.Verification;
|
using SideScrollerGame.Sim.Verification;
|
||||||
|
|
||||||
namespace SideScrollerGame.Sim.Tests;
|
namespace SideScrollerGame.Sim.Tests;
|
||||||
|
|
||||||
internal static class SimulationTestFactory
|
internal static class SimulationTestFactory
|
||||||
{
|
{
|
||||||
public static GameDefinition CreateGameDefinition(AxisAlignedBounds? worldBounds = null, ImmutableArray<HazardDefinition> hazards = default, ImmutableArray<TriggerDefinition> triggers = default)
|
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), ImmutableArray.Create(new PlayerDefinition(new(1), new(10, 20), 10)));
|
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)
|
public static SimulationConfig CreateConfig(VerificationMode verificationMode = VerificationMode.None)
|
||||||
|
|||||||
Reference in New Issue
Block a user