Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Stable replication id. Arch entity ids are recycled, so the client gets this
|
||||
/// monotonically increasing value instead.
|
||||
/// </summary>
|
||||
public struct NetworkId
|
||||
{
|
||||
public uint Value;
|
||||
|
||||
public NetworkId(uint value) => Value = value;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>Marks an entity as driven by a connected client's input.</summary>
|
||||
public struct PlayerControl
|
||||
{
|
||||
/// <summary>Network id of the owning connection.</summary>
|
||||
public uint PlayerId;
|
||||
|
||||
/// <summary>Latest intent received from that connection.</summary>
|
||||
public InputButtons Buttons;
|
||||
|
||||
/// <summary>Sequence number of that intent; reserved for prediction/reconciliation.</summary>
|
||||
public uint LastInputSequence;
|
||||
|
||||
/// <summary>Movement speed in units per second.</summary>
|
||||
public float Speed;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>World-space position in simulation units.</summary>
|
||||
public struct Position
|
||||
{
|
||||
public float X;
|
||||
public float Y;
|
||||
|
||||
public Position(float x, float y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>Everything the client needs to draw the entity; replicated verbatim in snapshots.</summary>
|
||||
public struct Renderable
|
||||
{
|
||||
public EntityKind Kind;
|
||||
public float Radius;
|
||||
|
||||
/// <summary>Packed 0x00RRGGBB.</summary>
|
||||
public uint Color;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace HSchool.Simulation.Components;
|
||||
|
||||
/// <summary>Simulation units per second, integrated by <c>MovementSystem</c>.</summary>
|
||||
public struct Velocity
|
||||
{
|
||||
public float X;
|
||||
public float Y;
|
||||
|
||||
public Velocity(float x, float y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Simulation.Components;
|
||||
using HSchool.Simulation.Systems;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// The authoritative world: an Arch <see cref="World"/> plus the fixed-step system pipeline.
|
||||
/// Not thread-safe by design — only the game loop thread may touch it, everything else
|
||||
/// goes through the command queue in the server layer.
|
||||
/// </summary>
|
||||
public sealed class GameWorld : IDisposable
|
||||
{
|
||||
private readonly World _world;
|
||||
private readonly ISimulationSystem[] _systems;
|
||||
private readonly Dictionary<uint, Entity> _playerEntities = [];
|
||||
|
||||
private uint _nextNetworkId = 1;
|
||||
private bool _disposed;
|
||||
|
||||
public GameWorld(SimulationOptions? options = null)
|
||||
{
|
||||
Options = options ?? new SimulationOptions();
|
||||
_world = World.Create();
|
||||
_systems =
|
||||
[
|
||||
new PlayerInputSystem(),
|
||||
new MovementSystem(),
|
||||
new WorldBoundsSystem(),
|
||||
];
|
||||
|
||||
SpawnObstacles();
|
||||
}
|
||||
|
||||
public SimulationOptions Options { get; }
|
||||
|
||||
/// <summary>Number of fixed steps simulated so far.</summary>
|
||||
public uint CurrentTick { get; private set; }
|
||||
|
||||
public int PlayerCount => _playerEntities.Count;
|
||||
|
||||
public int EntityCount => _world.CountEntities(new QueryDescription().WithAll<NetworkId>());
|
||||
|
||||
/// <summary>Adds a player body. Returns its replication id.</summary>
|
||||
public uint SpawnPlayer(uint playerId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_playerEntities.ContainsKey(playerId))
|
||||
{
|
||||
throw new InvalidOperationException($"Player {playerId} is already spawned.");
|
||||
}
|
||||
|
||||
var networkId = _nextNetworkId++;
|
||||
var (x, y) = SpawnPoint(playerId);
|
||||
|
||||
var entity = _world.Create(
|
||||
new NetworkId(networkId),
|
||||
new Position(x, y),
|
||||
new Velocity(0f, 0f),
|
||||
new PlayerControl
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Buttons = InputButtons.None,
|
||||
Speed = Options.PlayerSpeed,
|
||||
},
|
||||
new Renderable
|
||||
{
|
||||
Kind = EntityKind.Player,
|
||||
Radius = Options.PlayerRadius,
|
||||
Color = Palette.ForPlayer(playerId),
|
||||
});
|
||||
|
||||
_playerEntities[playerId] = entity;
|
||||
return networkId;
|
||||
}
|
||||
|
||||
public void DespawnPlayer(uint playerId)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (_playerEntities.Remove(playerId, out var entity) && _world.IsAlive(entity))
|
||||
{
|
||||
_world.Destroy(entity);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores the latest intent for a player; applied on the next tick.</summary>
|
||||
public void ApplyInput(uint playerId, InputButtons buttons, uint sequence)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!_playerEntities.TryGetValue(playerId, out var entity) || !_world.IsAlive(entity))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ref var control = ref _world.Get<PlayerControl>(entity);
|
||||
|
||||
// Late/duplicate packets carry a stale sequence; the newest intent wins.
|
||||
if (sequence < control.LastInputSequence)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
control.Buttons = buttons;
|
||||
control.LastInputSequence = sequence;
|
||||
}
|
||||
|
||||
/// <summary>Runs one fixed step of the pipeline.</summary>
|
||||
public void Tick()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
CurrentTick++;
|
||||
var context = new SimulationContext(CurrentTick, Options.FixedDeltaTime, Options);
|
||||
|
||||
foreach (var system in _systems)
|
||||
{
|
||||
system.Update(_world, in context);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fills <paramref name="buffer"/> with the replicated state of every visible entity.</summary>
|
||||
public void CaptureSnapshot(List<EntitySnapshot> buffer)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(buffer);
|
||||
|
||||
buffer.Clear();
|
||||
|
||||
var query = new QueryDescription().WithAll<NetworkId, Position, Renderable>();
|
||||
_world.Query(in query, (ref NetworkId id, ref Position position, ref Renderable renderable) =>
|
||||
{
|
||||
buffer.Add(new EntitySnapshot(
|
||||
id.Value,
|
||||
renderable.Kind,
|
||||
position.X,
|
||||
position.Y,
|
||||
renderable.Radius,
|
||||
renderable.Color));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Replication id of a connected player, or <c>null</c> if it is not spawned.</summary>
|
||||
public uint? GetNetworkId(uint playerId) =>
|
||||
_playerEntities.TryGetValue(playerId, out var entity) && _world.IsAlive(entity)
|
||||
? _world.Get<NetworkId>(entity).Value
|
||||
: null;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
World.Destroy(_world);
|
||||
}
|
||||
|
||||
/// <summary>A few static blocks so an empty world still shows something on screen.</summary>
|
||||
private void SpawnObstacles()
|
||||
{
|
||||
ReadOnlySpan<(float X, float Y, float Radius)> layout =
|
||||
[
|
||||
(0.5f, 0.5f, 70f),
|
||||
(0.2f, 0.25f, 45f),
|
||||
(0.8f, 0.75f, 45f),
|
||||
];
|
||||
|
||||
foreach (var (relativeX, relativeY, radius) in layout)
|
||||
{
|
||||
_world.Create(
|
||||
new NetworkId(_nextNetworkId++),
|
||||
new Position(Options.WorldWidth * relativeX, Options.WorldHeight * relativeY),
|
||||
new Renderable
|
||||
{
|
||||
Kind = EntityKind.Obstacle,
|
||||
Radius = radius,
|
||||
Color = Palette.Obstacle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deterministic spread of spawn points around the centre of the field.</summary>
|
||||
private (float X, float Y) SpawnPoint(uint playerId)
|
||||
{
|
||||
const int Slots = 8;
|
||||
var slot = (int)(playerId % Slots);
|
||||
var angle = slot * (2f * MathF.PI / Slots);
|
||||
var radius = MathF.Min(Options.WorldWidth, Options.WorldHeight) * 0.3f;
|
||||
|
||||
return (
|
||||
(Options.WorldWidth * 0.5f) + (MathF.Cos(angle) * radius),
|
||||
(Options.WorldHeight * 0.5f) + (MathF.Sin(angle) * radius));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Simulation</RootNamespace>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Arch" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
using Arch.Core;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// One stage of the fixed-step pipeline. Systems run in registration order on the
|
||||
/// loop thread and must not capture per-step state.
|
||||
/// </summary>
|
||||
public interface ISimulationSystem
|
||||
{
|
||||
void Update(World world, in SimulationContext context);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Stable colours for replicated entities, packed as 0x00RRGGBB.</summary>
|
||||
public static class Palette
|
||||
{
|
||||
public const uint Obstacle = 0x3A4553;
|
||||
|
||||
private static readonly uint[] PlayerColors =
|
||||
[
|
||||
0x4CC9F0,
|
||||
0xF72585,
|
||||
0x7BF1A8,
|
||||
0xFFB703,
|
||||
0xB388EB,
|
||||
0xFF7A5C,
|
||||
0x5CE1E6,
|
||||
0xE9FF70,
|
||||
];
|
||||
|
||||
/// <summary>Same player id always gets the same colour, on both server and client.</summary>
|
||||
public static uint ForPlayer(uint playerId) => PlayerColors[playerId % (uint)PlayerColors.Length];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Per-step data handed to every system.</summary>
|
||||
/// <param name="Tick">Index of the step being simulated.</param>
|
||||
/// <param name="DeltaTime">Fixed step length in seconds.</param>
|
||||
/// <param name="Options">Simulation tunables.</param>
|
||||
public readonly record struct SimulationContext(uint Tick, float DeltaTime, SimulationOptions Options);
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>Tunables of the authoritative simulation. Bound from the <c>Simulation</c> config section.</summary>
|
||||
public sealed class SimulationOptions
|
||||
{
|
||||
public const string SectionName = "Simulation";
|
||||
|
||||
/// <summary>Fixed simulation steps per second.</summary>
|
||||
public int TickRate { get; set; } = 20;
|
||||
|
||||
public float WorldWidth { get; set; } = 1600f;
|
||||
|
||||
public float WorldHeight { get; set; } = 900f;
|
||||
|
||||
public float PlayerSpeed { get; set; } = 260f;
|
||||
|
||||
public float PlayerRadius { get; set; } = 18f;
|
||||
|
||||
/// <summary>Length of one fixed step.</summary>
|
||||
public float FixedDeltaTime => 1f / TickRate;
|
||||
|
||||
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Simulation.Components;
|
||||
|
||||
namespace HSchool.Simulation.Systems;
|
||||
|
||||
/// <summary>Integrates velocity into position with the fixed step.</summary>
|
||||
public sealed class MovementSystem : ISimulationSystem
|
||||
{
|
||||
private static readonly QueryDescription Query =
|
||||
new QueryDescription().WithAll<Position, Velocity>();
|
||||
|
||||
public void Update(World world, in SimulationContext context)
|
||||
{
|
||||
var deltaTime = context.DeltaTime;
|
||||
|
||||
world.Query(in Query, (ref Position position, ref Velocity velocity) =>
|
||||
{
|
||||
position.X += velocity.X * deltaTime;
|
||||
position.Y += velocity.Y * deltaTime;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Simulation.Components;
|
||||
|
||||
namespace HSchool.Simulation.Systems;
|
||||
|
||||
/// <summary>Turns the latest button mask of every player into a velocity vector.</summary>
|
||||
public sealed class PlayerInputSystem : ISimulationSystem
|
||||
{
|
||||
private static readonly QueryDescription Query =
|
||||
new QueryDescription().WithAll<PlayerControl, Velocity>();
|
||||
|
||||
public void Update(World world, in SimulationContext context)
|
||||
{
|
||||
world.Query(in Query, (ref PlayerControl control, ref Velocity velocity) =>
|
||||
{
|
||||
var x = 0f;
|
||||
var y = 0f;
|
||||
|
||||
if ((control.Buttons & InputButtons.Left) != 0) x -= 1f;
|
||||
if ((control.Buttons & InputButtons.Right) != 0) x += 1f;
|
||||
if ((control.Buttons & InputButtons.Up) != 0) y -= 1f;
|
||||
if ((control.Buttons & InputButtons.Down) != 0) y += 1f;
|
||||
|
||||
// Normalize so diagonals are not faster than the cardinal directions.
|
||||
if (x != 0f && y != 0f)
|
||||
{
|
||||
const float InverseSqrt2 = 0.70710678f;
|
||||
x *= InverseSqrt2;
|
||||
y *= InverseSqrt2;
|
||||
}
|
||||
|
||||
velocity.X = x * control.Speed;
|
||||
velocity.Y = y * control.Speed;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Simulation.Components;
|
||||
|
||||
namespace HSchool.Simulation.Systems;
|
||||
|
||||
/// <summary>Keeps every body inside the play field and kills the velocity it pushed with.</summary>
|
||||
public sealed class WorldBoundsSystem : ISimulationSystem
|
||||
{
|
||||
private static readonly QueryDescription Query =
|
||||
new QueryDescription().WithAll<Position, Velocity, Renderable>();
|
||||
|
||||
public void Update(World world, in SimulationContext context)
|
||||
{
|
||||
var width = context.Options.WorldWidth;
|
||||
var height = context.Options.WorldHeight;
|
||||
|
||||
world.Query(in Query, (ref Position position, ref Velocity velocity, ref Renderable renderable) =>
|
||||
{
|
||||
var minX = renderable.Radius;
|
||||
var maxX = width - renderable.Radius;
|
||||
var minY = renderable.Radius;
|
||||
var maxY = height - renderable.Radius;
|
||||
|
||||
if (position.X < minX)
|
||||
{
|
||||
position.X = minX;
|
||||
velocity.X = 0f;
|
||||
}
|
||||
else if (position.X > maxX)
|
||||
{
|
||||
position.X = maxX;
|
||||
velocity.X = 0f;
|
||||
}
|
||||
|
||||
if (position.Y < minY)
|
||||
{
|
||||
position.Y = minY;
|
||||
velocity.Y = 0f;
|
||||
}
|
||||
else if (position.Y > maxY)
|
||||
{
|
||||
position.Y = maxY;
|
||||
velocity.Y = 0f;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user