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.
ci / server (push) Failing after 4m10s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 11:11:48 +03:00
parent 84aafb0b69
commit e6739e7912
84 changed files with 5698 additions and 2 deletions
@@ -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;
}
}
+199
View File
@@ -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);
}
+22
View File
@@ -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;
}
});
}
}