Add platformer locomotion slice

This commit is contained in:
2026-04-16 12:32:38 +02:00
parent 45181d1f78
commit 21a8b8bedb
15 changed files with 604 additions and 28 deletions

View File

@@ -15,4 +15,4 @@ public sealed record GameDefinition
public LevelDefinition Level { get; init; }
public ImmutableArray<PlayerDefinition> Players { get; init; }
}
}

View File

@@ -6,11 +6,12 @@ namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
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;
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; }
@@ -18,4 +19,6 @@ public sealed record LevelDefinition
public ImmutableArray<HazardDefinition> Hazards { get; init; }
public ImmutableArray<TriggerDefinition> Triggers { get; init; }
public ImmutableArray<SolidPlatformDefinition> Platforms { get; init; }
}

View File

@@ -6,11 +6,17 @@ namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
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;
SpawnPosition = spawnPosition;
MaxHealth = maxHealth;
UsesPlatformerMotion = usesPlatformerMotion;
MoveSpeedPerTick = moveSpeedPerTick;
GravityPerTick = gravityPerTick;
JumpVelocityPerTick = jumpVelocityPerTick;
CoyoteTicks = coyoteTicks;
JumpBufferTicks = jumpBufferTicks;
}
public PlayerId PlayerId { get; init; }
@@ -18,4 +24,16 @@ public sealed record PlayerDefinition
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

@@ -6,13 +6,15 @@ namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
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;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
Health = health;
VerticalVelocity = verticalVelocity;
IsGrounded = isGrounded;
}
public PlayerId PlayerId { get; init; }
@@ -24,4 +26,8 @@ public sealed record PlayerSnapshot
public sbyte MoveAxisY { get; init; }
public int Health { get; init; }
public FixPoint16 VerticalVelocity { get; init; }
public bool IsGrounded { get; init; }
}

View File

@@ -5,7 +5,7 @@ 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)
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;
@@ -16,11 +16,15 @@ public sealed class PlayerState
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);
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask, Health, VerticalVelocity, IsGrounded, LastGroundedTick, BufferedJumpTick);
}
public void SetMoveAxis(sbyte x, sbyte y)
@@ -46,6 +50,11 @@ public sealed class PlayerState
SelectedWeaponSlot = slotIndex;
}
public bool IsButtonPressed(InputButton button)
{
return (ButtonMask & (1 << (int)button)) != 0;
}
public void Advance()
{
Position += new FixPointVector2(MoveAxisX, MoveAxisY);
@@ -56,11 +65,46 @@ public sealed class PlayerState
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; }
@@ -78,4 +122,12 @@ public sealed class PlayerState
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

@@ -26,6 +26,18 @@ internal static class GameDefinitionHasher
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]
@@ -36,6 +48,8 @@ internal static class GameDefinitionHasher
public ImmutableArray<HazardDefinitionDocument> Hazards { get; init; }
public ImmutableArray<TriggerDefinitionDocument> Triggers { get; init; }
public ImmutableArray<SolidPlatformDefinitionDocument> Platforms { get; init; }
}
[ExcludeFromCodeCoverage]
@@ -70,6 +84,14 @@ internal static class GameDefinitionHasher
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);
@@ -80,7 +102,13 @@ internal static class GameDefinitionHasher
PlayerId = player.PlayerId.Value,
SpawnX = player.SpawnPosition.m_X.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
{
Level = new()
{
WorldBounds = ToDocument(gameDefinition.Level.WorldBounds),
Hazards = hazards.ToImmutableArray(),
Triggers = triggers.ToImmutableArray()
Triggers = triggers.ToImmutableArray(),
Platforms = platforms.ToImmutableArray()
},
Players = players.ToImmutableArray()
});

View File

@@ -49,6 +49,14 @@ internal static class SimulationStateSerializer
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)
@@ -67,7 +75,11 @@ internal static class SimulationStateSerializer
AimAxisY = player.AimAxisY,
SelectedWeaponSlot = player.SelectedWeaponSlot,
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);
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));
}

View File

@@ -44,7 +44,7 @@ public sealed class Simulation
PreviousSnapshot = CurrentSnapshot;
List<SimulationEvent> events = new();
ApplyActions(actions);
ApplyActions(actions.Tick, actions);
var nextRandomState = AdvanceRandom();
AdvancePlayers(actions.Tick, events);
ResolveBounds(actions.Tick, events);
@@ -84,7 +84,10 @@ public sealed class Simulation
var players = ImmutableArray.CreateBuilder<PlayerState>(gameDefinition.Players.Length);
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);
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))
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);
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());
}
private void ApplyActions(TickActionBatch actions)
private void ApplyActions(int tick, TickActionBatch actions)
{
foreach (var action in actions.Actions)
{
@@ -138,7 +156,11 @@ public sealed class Simulation
CurrentState.GetRequiredPlayer(aimAxisChanged.PlayerId).SetAimAxis(aimAxisChanged.X, aimAxisChanged.Y);
break;
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;
case WeaponSlotSelected weaponSlotSelected:
CurrentState.GetRequiredPlayer(weaponSlotSelected.PlayerId).SelectWeaponSlot(weaponSlotSelected.SlotIndex);
@@ -153,7 +175,10 @@ public sealed class Simulation
{
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();
events.Add(new("PlayerMoved", tick, player.PlayerId));
@@ -165,6 +190,9 @@ public sealed class Simulation
{
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)
{
@@ -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)
{
foreach (var player in CurrentState.Players)