Add bounds hazards and triggers

This commit is contained in:
2026-04-16 11:50:37 +02:00
parent c79d5c8f0a
commit 45181d1f78
17 changed files with 405 additions and 25 deletions

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

@@ -6,10 +6,13 @@ namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record GameDefinition
{
public GameDefinition(ImmutableArray<PlayerDefinition> players)
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,21 @@
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)
{
WorldBounds = worldBounds;
Hazards = hazards.IsDefault ? ImmutableArray<HazardDefinition>.Empty : hazards;
Triggers = triggers.IsDefault ? ImmutableArray<TriggerDefinition>.Empty : triggers;
}
public AxisAlignedBounds WorldBounds { get; init; }
public ImmutableArray<HazardDefinition> Hazards { get; init; }
public ImmutableArray<TriggerDefinition> Triggers { get; init; }
}

View File

@@ -6,13 +6,16 @@ namespace SideScrollerGame.Sim.Definitions;
[ExcludeFromCodeCoverage]
public sealed record PlayerDefinition
{
public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition)
public PlayerDefinition(PlayerId playerId, FixPointVector2 spawnPosition, int maxHealth)
{
PlayerId = playerId;
SpawnPosition = spawnPosition;
MaxHealth = maxHealth;
}
public PlayerId PlayerId { get; init; }
public FixPointVector2 SpawnPosition { get; init; }
public int MaxHealth { 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

@@ -6,12 +6,13 @@ namespace SideScrollerGame.Sim.Runtime;
[ExcludeFromCodeCoverage]
public sealed record PlayerSnapshot
{
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY)
public PlayerSnapshot(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, int health)
{
PlayerId = playerId;
Position = position;
MoveAxisX = moveAxisX;
MoveAxisY = moveAxisY;
Health = health;
}
public PlayerId PlayerId { get; init; }
@@ -21,4 +22,6 @@ public sealed record PlayerSnapshot
public sbyte MoveAxisX { get; init; }
public sbyte MoveAxisY { get; init; }
public int Health { 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)
public PlayerState(PlayerId playerId, FixPointVector2 position, sbyte moveAxisX, sbyte moveAxisY, short aimAxisX, short aimAxisY, int selectedWeaponSlot, int buttonMask, int health)
{
PlayerId = playerId;
Position = position;
@@ -15,11 +15,12 @@ public sealed class PlayerState
AimAxisY = aimAxisY;
SelectedWeaponSlot = selectedWeaponSlot;
ButtonMask = buttonMask;
Health = health;
}
public PlayerState Clone()
{
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask);
return new(PlayerId, Position, MoveAxisX, MoveAxisY, AimAxisX, AimAxisY, SelectedWeaponSlot, ButtonMask, Health);
}
public void SetMoveAxis(sbyte x, sbyte y)
@@ -50,6 +51,16 @@ public sealed class PlayerState
Position += new FixPointVector2(MoveAxisX, MoveAxisY);
}
public void ApplyDamage(int damage)
{
Health = System.Math.Max(0, Health - damage);
}
public void SetPosition(FixPointVector2 position)
{
Position = position;
}
public PlayerId PlayerId { get; }
public FixPointVector2 Position { get; private set; }
@@ -65,4 +76,6 @@ public sealed class PlayerState
public int SelectedWeaponSlot { get; private set; }
public int ButtonMask { get; private set; }
public int Health { get; private set; }
}

View File

@@ -4,13 +4,14 @@ namespace SideScrollerGame.Sim.Runtime;
public sealed class SimulationState
{
public SimulationState(int tick, int seed, ulong randomState, ulong lastRandomValue, ImmutableArray<PlayerState> players)
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()
@@ -19,7 +20,7 @@ public sealed class SimulationState
foreach (var player in Players)
builder.Add(player.Clone());
return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable());
return new(Tick, Seed, RandomState, LastRandomValue, builder.MoveToImmutable(), ActivatedTriggerIds);
}
public PlayerState GetRequiredPlayer(PlayerId playerId)
@@ -40,6 +41,15 @@ public sealed class SimulationState
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; }
@@ -49,4 +59,6 @@ public sealed class SimulationState
public ulong LastRandomValue { get; private set; }
public ImmutableArray<PlayerState> Players { get; }
public ImmutableHashSet<string> ActivatedTriggerIds { get; private set; }
}

View File

@@ -11,6 +11,8 @@ internal static class GameDefinitionHasher
[ExcludeFromCodeCoverage]
private sealed record GameDefinitionDocument
{
public LevelDefinitionDocument Level { get; init; } = null!;
public ImmutableArray<PlayerDefinitionDocument> Players { get; init; }
}
@@ -22,6 +24,50 @@ internal static class GameDefinitionHasher
public int SpawnX { get; init; }
public int SpawnY { get; init; }
public int MaxHealth { 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; }
}
[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;
}
public static int Compute(GameDefinition gameDefinition)
@@ -33,12 +79,55 @@ internal static class GameDefinitionHasher
{
PlayerId = player.PlayerId.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
});
}
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument { Players = players.ToImmutableArray() });
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
});
}
var bytes = JsonSerializer.SerializeToUtf8Bytes(new GameDefinitionDocument
{
Level = new()
{
WorldBounds = ToDocument(gameDefinition.Level.WorldBounds),
Hazards = hazards.ToImmutableArray(),
Triggers = triggers.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

@@ -23,6 +23,8 @@ internal static class SimulationStateSerializer
public ulong LastRandomValue { get; init; }
public ImmutableArray<PlayerStateDocument> Players { get; init; }
public ImmutableArray<string> ActivatedTriggerIds { get; init; }
}
[ExcludeFromCodeCoverage]
@@ -45,6 +47,8 @@ internal static class SimulationStateSerializer
public int SelectedWeaponSlot { get; init; }
public int ButtonMask { get; init; }
public int Health { get; init; }
}
public static byte[] Serialize(SimulationState state)
@@ -62,7 +66,8 @@ internal static class SimulationStateSerializer
AimAxisX = player.AimAxisX,
AimAxisY = player.AimAxisY,
SelectedWeaponSlot = player.SelectedWeaponSlot,
ButtonMask = player.ButtonMask
ButtonMask = player.ButtonMask,
Health = player.Health
});
}
@@ -73,7 +78,8 @@ internal static class SimulationStateSerializer
Seed = state.Seed,
RandomState = state.RandomState,
LastRandomValue = state.LastRandomValue,
Players = players.ToImmutableArray()
Players = players.ToImmutableArray(),
ActivatedTriggerIds = state.ActivatedTriggerIds.Order(StringComparer.Ordinal).ToImmutableArray()
});
}
@@ -85,10 +91,8 @@ 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));
}
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));
return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable());
return new(document.Tick, document.Seed, document.RandomState, document.LastRandomValue, players.MoveToImmutable(), document.ActivatedTriggerIds.ToImmutableHashSet(StringComparer.Ordinal));
}
}

View File

@@ -47,6 +47,9 @@ public sealed class Simulation
ApplyActions(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);
@@ -81,10 +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));
players.Add(new(player.PlayerId, player.SpawnPosition, 0, 0, 0, 0, 0, 0, player.MaxHealth));
var normalizedSeed = NormalizeSeed(seed);
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable());
return new(0, seed, normalizedSeed, 0, players.MoveToImmutable(), ImmutableHashSet<string>.Empty);
}
private static ulong NormalizeSeed(int seed)
@@ -99,6 +102,12 @@ public sealed class Simulation
{
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.");
}
}
@@ -111,7 +120,7 @@ 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));
players.Add(new(player.PlayerId, player.Position, player.MoveAxisX, player.MoveAxisY, player.Health));
return new(state.Tick, stateHash, state.LastRandomValue, players.MoveToImmutable());
}
@@ -152,6 +161,48 @@ public sealed class Simulation
}
}
private void ResolveBounds(int tick, List<SimulationEvent> events)
{
foreach (var player in CurrentState.Players)
{
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 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);