Add bounds hazards and triggers
This commit is contained in:
28
src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs
Normal file
28
src/SideScrollerGame.Sim/Definitions/AxisAlignedBounds.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
20
src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs
Normal file
20
src/SideScrollerGame.Sim/Definitions/HazardDefinition.cs
Normal 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; }
|
||||
}
|
||||
21
src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs
Normal file
21
src/SideScrollerGame.Sim/Definitions/LevelDefinition.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
20
src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs
Normal file
20
src/SideScrollerGame.Sim/Definitions/TriggerDefinition.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user