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,22 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var server = builder.AddProject<Projects.HSchool_Server>("server")
|
||||
.WithHttpHealthCheck("/health")
|
||||
.WithExternalHttpEndpoints();
|
||||
|
||||
// Integration tests and CI run headless: no Node, no dev server, just the game server.
|
||||
var headless = builder.Configuration.GetValue("HSchool:Headless", false);
|
||||
|
||||
if (!headless)
|
||||
{
|
||||
var client = builder.AddViteApp("client", "../HSchool.Client")
|
||||
.WithReference(server)
|
||||
.WaitFor(server);
|
||||
|
||||
// On publish the built client is copied into the server image and served from wwwroot.
|
||||
server.PublishWithContainerFiles(client, "wwwroot");
|
||||
}
|
||||
|
||||
builder.Build().Run();
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.4.6">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>HSchool.AppHost</RootNamespace>
|
||||
<UserSecretsId>hschool-apphost-8f2c1d4a</UserSecretsId>
|
||||
<IsAspireHost>true</IsAspireHost>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" />
|
||||
<PackageReference Include="Aspire.Hosting.JavaScript" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.Server\HSchool.Server.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17180;http://localhost:15180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21180",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23180",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22180"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_ALLOW_UNSECURED_TRANSPORT": "true",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19180",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18180",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20180"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Aspire.Hosting.Dcp": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>h-school</title>
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<div id="hud">
|
||||
<span data-hud="status">connecting…</span>
|
||||
<span data-hud="tick">tick 0</span>
|
||||
<span data-hud="ping">-- ms</span>
|
||||
<span data-hud="entities">0 entities</span>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "hschool-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"pixi.js": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ConnectionStatus } from '../net/connection.ts';
|
||||
|
||||
/** Thin wrapper over the status line in `index.html`. */
|
||||
export class Hud {
|
||||
private readonly fields = new Map<string, HTMLElement>();
|
||||
|
||||
constructor(root: ParentNode = document) {
|
||||
for (const element of root.querySelectorAll<HTMLElement>('[data-hud]')) {
|
||||
this.fields.set(element.dataset['hud'] ?? '', element);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(status: ConnectionStatus): void {
|
||||
this.set('status', status);
|
||||
}
|
||||
|
||||
setTick(tick: number): void {
|
||||
this.set('tick', `tick ${tick}`);
|
||||
}
|
||||
|
||||
setPing(rttMs: number): void {
|
||||
this.set('ping', `${Math.round(rttMs)} ms`);
|
||||
}
|
||||
|
||||
setEntityCount(count: number): void {
|
||||
this.set('entities', `${count} entities`);
|
||||
}
|
||||
|
||||
private set(field: string, text: string): void {
|
||||
const element = this.fields.get(field);
|
||||
if (element !== undefined) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { InputButtons } from '../net/protocol.ts';
|
||||
|
||||
/** How often the button mask is pushed to the server, independent of the render rate. */
|
||||
export const INPUT_SEND_HZ = 30;
|
||||
|
||||
const KEY_BINDINGS: Readonly<Record<string, number>> = {
|
||||
KeyW: InputButtons.Up,
|
||||
ArrowUp: InputButtons.Up,
|
||||
KeyS: InputButtons.Down,
|
||||
ArrowDown: InputButtons.Down,
|
||||
KeyA: InputButtons.Left,
|
||||
ArrowLeft: InputButtons.Left,
|
||||
KeyD: InputButtons.Right,
|
||||
ArrowRight: InputButtons.Right,
|
||||
};
|
||||
|
||||
/** Tracks the keyboard and pushes the current mask on a fixed cadence. */
|
||||
export class InputTracker {
|
||||
private buttons = InputButtons.None;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
const button = KEY_BINDINGS[event.code];
|
||||
if (button !== undefined) {
|
||||
this.buttons |= button;
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onKeyUp = (event: KeyboardEvent): void => {
|
||||
const button = KEY_BINDINGS[event.code];
|
||||
if (button !== undefined) {
|
||||
this.buttons &= ~button;
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Alt-tabbing away must not leave a key stuck down.
|
||||
private readonly onBlur = (): void => {
|
||||
this.buttons = InputButtons.None;
|
||||
};
|
||||
|
||||
constructor(private readonly send: (buttons: number) => void) {}
|
||||
|
||||
get current(): number {
|
||||
return this.buttons;
|
||||
}
|
||||
|
||||
start(target: Window = window): void {
|
||||
target.addEventListener('keydown', this.onKeyDown);
|
||||
target.addEventListener('keyup', this.onKeyUp);
|
||||
target.addEventListener('blur', this.onBlur);
|
||||
|
||||
this.timer = setInterval(() => this.send(this.buttons), 1000 / INPUT_SEND_HZ);
|
||||
}
|
||||
|
||||
stop(target: Window = window): void {
|
||||
target.removeEventListener('keydown', this.onKeyDown);
|
||||
target.removeEventListener('keyup', this.onKeyUp);
|
||||
target.removeEventListener('blur', this.onBlur);
|
||||
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Application, Container, Graphics } from 'pixi.js';
|
||||
import { EntityKind, type EntitySnapshot } from '../net/protocol.ts';
|
||||
|
||||
const FIELD_BORDER_COLOR = 0x2a3242;
|
||||
const OWN_PLAYER_RING_COLOR = 0xffffff;
|
||||
|
||||
/**
|
||||
* Draws the interpolated world state. One PixiJS `Graphics` per replicated entity,
|
||||
* created on first sight and destroyed when the entity disappears from a snapshot.
|
||||
*/
|
||||
export class WorldRenderer {
|
||||
private readonly world = new Container();
|
||||
private readonly field = new Graphics();
|
||||
private readonly sprites = new Map<number, Graphics>();
|
||||
|
||||
private worldWidth = 1600;
|
||||
private worldHeight = 900;
|
||||
private ownEntityId = 0;
|
||||
|
||||
constructor(private readonly app: Application) {
|
||||
this.world.addChild(this.field);
|
||||
this.app.stage.addChild(this.world);
|
||||
this.app.renderer.on('resize', () => this.layout());
|
||||
}
|
||||
|
||||
/** Called on every welcome frame: the server owns the field size. */
|
||||
configure(worldWidth: number, worldHeight: number, ownEntityId: number): void {
|
||||
this.worldWidth = worldWidth;
|
||||
this.worldHeight = worldHeight;
|
||||
this.ownEntityId = ownEntityId;
|
||||
|
||||
this.field
|
||||
.clear()
|
||||
.rect(0, 0, worldWidth, worldHeight)
|
||||
.stroke({ color: FIELD_BORDER_COLOR, width: 4 });
|
||||
|
||||
this.layout();
|
||||
}
|
||||
|
||||
draw(entities: readonly EntitySnapshot[]): void {
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const entity of entities) {
|
||||
seen.add(entity.id);
|
||||
|
||||
let sprite = this.sprites.get(entity.id);
|
||||
if (sprite === undefined) {
|
||||
sprite = this.createSprite(entity);
|
||||
this.sprites.set(entity.id, sprite);
|
||||
this.world.addChild(sprite);
|
||||
}
|
||||
|
||||
sprite.x = entity.x;
|
||||
sprite.y = entity.y;
|
||||
}
|
||||
|
||||
for (const [id, sprite] of this.sprites) {
|
||||
if (!seen.has(id)) {
|
||||
sprite.destroy();
|
||||
this.sprites.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createSprite(entity: EntitySnapshot): Graphics {
|
||||
const sprite = new Graphics();
|
||||
|
||||
if (entity.kind === EntityKind.Obstacle) {
|
||||
sprite.roundRect(-entity.radius, -entity.radius, entity.radius * 2, entity.radius * 2, 8);
|
||||
} else {
|
||||
sprite.circle(0, 0, entity.radius);
|
||||
}
|
||||
|
||||
sprite.fill({ color: entity.color });
|
||||
|
||||
if (entity.id === this.ownEntityId) {
|
||||
sprite.circle(0, 0, entity.radius + 6).stroke({ color: OWN_PLAYER_RING_COLOR, width: 2, alpha: 0.9 });
|
||||
}
|
||||
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/** Fits the whole field on screen with letterboxing, so every client sees the same area. */
|
||||
private layout(): void {
|
||||
const { width, height } = this.app.renderer.screen;
|
||||
const scale = Math.min(width / this.worldWidth, height / this.worldHeight) * 0.95;
|
||||
|
||||
this.world.scale.set(scale);
|
||||
this.world.x = (width - this.worldWidth * scale) / 2;
|
||||
this.world.y = (height - this.worldHeight * scale) / 2;
|
||||
}
|
||||
|
||||
/** Drops every sprite, e.g. after a reconnect assigns new replication ids. */
|
||||
reset(): void {
|
||||
for (const sprite of this.sprites.values()) {
|
||||
sprite.destroy();
|
||||
}
|
||||
this.sprites.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SnapshotBuffer } from './snapshotBuffer.ts';
|
||||
import { EntityKind, type SnapshotMessage } from '../net/protocol.ts';
|
||||
|
||||
function snapshot(tick: number, x: number): SnapshotMessage {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
tick,
|
||||
entities: [{ id: 1, kind: EntityKind.Player, x, y: 0, radius: 10, color: 0xffffff }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('SnapshotBuffer', () => {
|
||||
it('returns nothing before the first snapshot', () => {
|
||||
expect(new SnapshotBuffer().sample(1000)).toEqual([]);
|
||||
});
|
||||
|
||||
it('blends the two snapshots straddling the render time', () => {
|
||||
const buffer = new SnapshotBuffer(100);
|
||||
buffer.push(snapshot(1, 0), 1000);
|
||||
buffer.push(snapshot(2, 100), 1100);
|
||||
|
||||
// Render time 1050 sits halfway between the two receive timestamps.
|
||||
const entities = buffer.sample(1150);
|
||||
|
||||
expect(entities[0]?.x).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it('holds at the newest snapshot when the render time has caught up', () => {
|
||||
const buffer = new SnapshotBuffer(0);
|
||||
buffer.push(snapshot(1, 0), 1000);
|
||||
buffer.push(snapshot(2, 100), 1100);
|
||||
|
||||
expect(buffer.sample(5000)[0]?.x).toBe(100);
|
||||
expect(buffer.latestTick).toBe(2);
|
||||
});
|
||||
|
||||
it('drops the history when the tick goes backwards after a reconnect', () => {
|
||||
const buffer = new SnapshotBuffer(100);
|
||||
buffer.push(snapshot(50, 500), 1000);
|
||||
buffer.push(snapshot(1, 0), 2000);
|
||||
|
||||
expect(buffer.size).toBe(1);
|
||||
expect(buffer.latestTick).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { EntitySnapshot, SnapshotMessage } from '../net/protocol.ts';
|
||||
|
||||
/** How far in the past we render, so there is always a newer snapshot to interpolate towards. */
|
||||
export const DEFAULT_INTERPOLATION_DELAY_MS = 100;
|
||||
|
||||
const MAX_BUFFERED_SNAPSHOTS = 32;
|
||||
|
||||
interface BufferedSnapshot {
|
||||
readonly tick: number;
|
||||
readonly receivedAt: number;
|
||||
readonly entities: readonly EntitySnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the last few snapshots and samples them slightly in the past, blending the two
|
||||
* that straddle the render time. That is what turns 20 discrete server ticks into smooth
|
||||
* motion at display refresh rate.
|
||||
*/
|
||||
export class SnapshotBuffer {
|
||||
private readonly snapshots: BufferedSnapshot[] = [];
|
||||
|
||||
constructor(private readonly delayMs: number = DEFAULT_INTERPOLATION_DELAY_MS) {}
|
||||
|
||||
get latestTick(): number {
|
||||
return this.snapshots.at(-1)?.tick ?? 0;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.snapshots.length;
|
||||
}
|
||||
|
||||
push(message: SnapshotMessage, receivedAt: number): void {
|
||||
// Out-of-order frames cannot happen on a WebSocket, but a reconnect resets the tick.
|
||||
const previous = this.snapshots.at(-1);
|
||||
if (previous !== undefined && message.tick < previous.tick) {
|
||||
this.snapshots.length = 0;
|
||||
}
|
||||
|
||||
this.snapshots.push({ tick: message.tick, receivedAt, entities: message.entities });
|
||||
|
||||
if (this.snapshots.length > MAX_BUFFERED_SNAPSHOTS) {
|
||||
this.snapshots.splice(0, this.snapshots.length - MAX_BUFFERED_SNAPSHOTS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the interpolated world state for `now` (a `performance.now()` timestamp). */
|
||||
sample(now: number): readonly EntitySnapshot[] {
|
||||
if (this.snapshots.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.snapshots.length === 1) {
|
||||
return this.snapshots[0]!.entities;
|
||||
}
|
||||
|
||||
const renderTime = now - this.delayMs;
|
||||
|
||||
// Newest pair whose older half is at or before the render time.
|
||||
let older = this.snapshots[0]!;
|
||||
let newer = this.snapshots[1]!;
|
||||
for (let i = this.snapshots.length - 1; i > 0; i--) {
|
||||
if (this.snapshots[i - 1]!.receivedAt <= renderTime) {
|
||||
older = this.snapshots[i - 1]!;
|
||||
newer = this.snapshots[i]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const span = newer.receivedAt - older.receivedAt;
|
||||
const t = span <= 0 ? 1 : clamp01((renderTime - older.receivedAt) / span);
|
||||
|
||||
return interpolate(older.entities, newer.entities, t);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.snapshots.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function interpolate(
|
||||
older: readonly EntitySnapshot[],
|
||||
newer: readonly EntitySnapshot[],
|
||||
t: number,
|
||||
): readonly EntitySnapshot[] {
|
||||
if (t >= 1) {
|
||||
return newer;
|
||||
}
|
||||
|
||||
const previousById = new Map(older.map((entity) => [entity.id, entity]));
|
||||
|
||||
// Entities missing from `newer` are gone; entities missing from `older` just spawned
|
||||
// and are drawn at their first known position.
|
||||
return newer.map((entity) => {
|
||||
const previous = previousById.get(entity.id);
|
||||
if (previous === undefined) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
return {
|
||||
...entity,
|
||||
x: previous.x + (entity.x - previous.x) * t,
|
||||
y: previous.y + (entity.y - previous.y) * t,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Application } from 'pixi.js';
|
||||
import { GameConnection, gameSocketUrl } from './net/connection.ts';
|
||||
import { Hud } from './game/hud.ts';
|
||||
import { InputTracker } from './game/input.ts';
|
||||
import { WorldRenderer } from './game/renderer.ts';
|
||||
import { SnapshotBuffer } from './game/snapshotBuffer.ts';
|
||||
import './style.css';
|
||||
|
||||
const BACKGROUND_COLOR = 0x10141c;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = new Application();
|
||||
await app.init({
|
||||
background: BACKGROUND_COLOR,
|
||||
resizeTo: window,
|
||||
antialias: true,
|
||||
autoDensity: true,
|
||||
resolution: window.devicePixelRatio,
|
||||
});
|
||||
|
||||
document.getElementById('stage')?.appendChild(app.canvas);
|
||||
|
||||
const hud = new Hud();
|
||||
const renderer = new WorldRenderer(app);
|
||||
const snapshots = new SnapshotBuffer();
|
||||
|
||||
const connection = new GameConnection(gameSocketUrl(), playerName(), {
|
||||
onStatus: (status) => {
|
||||
hud.setStatus(status);
|
||||
if (status !== 'connected') {
|
||||
snapshots.clear();
|
||||
}
|
||||
},
|
||||
onWelcome: (welcome) => {
|
||||
// Replication ids are per-session, so anything drawn before this point is stale.
|
||||
renderer.reset();
|
||||
renderer.configure(welcome.worldWidth, welcome.worldHeight, welcome.playerEntityId);
|
||||
},
|
||||
onSnapshot: (snapshot, receivedAt) => {
|
||||
snapshots.push(snapshot, receivedAt);
|
||||
hud.setTick(snapshot.tick);
|
||||
hud.setEntityCount(snapshot.entities.length);
|
||||
},
|
||||
onLatency: (rttMs) => hud.setPing(rttMs),
|
||||
});
|
||||
|
||||
const input = new InputTracker((buttons) => connection.sendInput(buttons));
|
||||
|
||||
app.ticker.add(() => renderer.draw(snapshots.sample(performance.now())));
|
||||
|
||||
connection.connect();
|
||||
input.start();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
input.stop();
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps a name across reloads; replace with a real login when one exists. */
|
||||
function playerName(): string {
|
||||
const stored = localStorage.getItem('hschool.playerName');
|
||||
if (stored !== null) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const generated = `player-${Math.floor(Math.random() * 10000)}`;
|
||||
localStorage.setItem('hschool.playerName', generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
decodeServerMessage,
|
||||
encodeHello,
|
||||
encodeInput,
|
||||
encodePing,
|
||||
ProtocolError,
|
||||
type ServerMessage,
|
||||
type SnapshotMessage,
|
||||
type WelcomeMessage,
|
||||
} from './protocol.ts';
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
||||
|
||||
export interface ConnectionHandlers {
|
||||
onStatus?(status: ConnectionStatus): void;
|
||||
onWelcome?(message: WelcomeMessage): void;
|
||||
onSnapshot?(message: SnapshotMessage, receivedAt: number): void;
|
||||
/** Round-trip time in milliseconds. */
|
||||
onLatency?(rttMs: number): void;
|
||||
}
|
||||
|
||||
const PING_INTERVAL_MS = 2000;
|
||||
const RECONNECT_MIN_MS = 500;
|
||||
const RECONNECT_MAX_MS = 8000;
|
||||
|
||||
/**
|
||||
* Owns the WebSocket: handshake, reconnect with backoff, ping/pong and outbound input.
|
||||
* Rendering code only sees decoded messages.
|
||||
*/
|
||||
export class GameConnection {
|
||||
private socket: WebSocket | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay = RECONNECT_MIN_MS;
|
||||
private inputSequence = 0;
|
||||
private closedByUs = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly playerName: string,
|
||||
private readonly handlers: ConnectionHandlers = {},
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
this.closedByUs = false;
|
||||
this.handlers.onStatus?.(this.reconnectDelay === RECONNECT_MIN_MS ? 'connecting' : 'reconnecting');
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.reconnectDelay = RECONNECT_MIN_MS;
|
||||
socket.send(encodeHello(this.playerName));
|
||||
this.handlers.onStatus?.('connected');
|
||||
this.startPinging();
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
socket.addEventListener('close', () => this.handleClose());
|
||||
socket.addEventListener('error', () => socket.close());
|
||||
}
|
||||
|
||||
/** Sends the current button mask; called at a fixed rate by the input loop. */
|
||||
sendInput(buttons: number): void {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inputSequence = (this.inputSequence + 1) >>> 0;
|
||||
this.socket.send(encodeInput(this.inputSequence, buttons));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closedByUs = true;
|
||||
this.stopTimers();
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
this.handlers.onStatus?.('closed');
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message: ServerMessage | null;
|
||||
try {
|
||||
message = decodeServerMessage(event.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ProtocolError) {
|
||||
console.warn('Dropping malformed frame:', error.message);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (message === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'welcome':
|
||||
this.handlers.onWelcome?.(message);
|
||||
break;
|
||||
case 'snapshot':
|
||||
this.handlers.onSnapshot?.(message, performance.now());
|
||||
break;
|
||||
case 'pong':
|
||||
this.handlers.onLatency?.(Math.max(0, Date.now() - message.clientTimeMs));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(): void {
|
||||
this.stopTimers();
|
||||
this.socket = null;
|
||||
|
||||
if (this.closedByUs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlers.onStatus?.('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
}
|
||||
|
||||
private startPinging(): void {
|
||||
this.stopPinging();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(encodePing(Date.now()));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopPinging(): void {
|
||||
if (this.pingTimer !== null) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private stopTimers(): void {
|
||||
this.stopPinging();
|
||||
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the game socket URL from the page origin, so the Vite proxy handles it in dev. */
|
||||
export function gameSocketUrl(path = '/ws/game'): string {
|
||||
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${scheme}//${location.host}${path}`;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeServerMessage,
|
||||
encodeHello,
|
||||
encodeInput,
|
||||
encodePing,
|
||||
EntityKind,
|
||||
InputButtons,
|
||||
MessageType,
|
||||
ProtocolError,
|
||||
PROTOCOL_VERSION,
|
||||
} from './protocol.ts';
|
||||
|
||||
/**
|
||||
* These byte layouts are the contract with `ProtocolCodec.cs`. If a test here has to
|
||||
* change, the C# codec and `docs/protocol.md` change with it.
|
||||
*/
|
||||
describe('client encoders', () => {
|
||||
it('writes a hello frame with version and UTF-8 name', () => {
|
||||
const view = new DataView(encodeHello('ada'));
|
||||
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientHello);
|
||||
expect(view.getUint8(1)).toBe(PROTOCOL_VERSION);
|
||||
expect(view.getUint8(2)).toBe(3);
|
||||
expect(view.byteLength).toBe(6);
|
||||
});
|
||||
|
||||
it('clamps oversized names to 32 bytes', () => {
|
||||
const view = new DataView(encodeHello('x'.repeat(100)));
|
||||
|
||||
expect(view.getUint8(2)).toBe(32);
|
||||
expect(view.byteLength).toBe(35);
|
||||
});
|
||||
|
||||
it('writes an input frame little-endian', () => {
|
||||
const buttons = InputButtons.Up | InputButtons.Right;
|
||||
const view = new DataView(encodeInput(0x01020304, buttons));
|
||||
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientInput);
|
||||
expect(view.getUint32(1, true)).toBe(0x01020304);
|
||||
expect(view.getUint8(5)).toBe(buttons);
|
||||
});
|
||||
|
||||
it('writes a ping frame carrying the client clock', () => {
|
||||
const view = new DataView(encodePing(1_700_000_000_123));
|
||||
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientPing);
|
||||
expect(Number(view.getBigInt64(1, true))).toBe(1_700_000_000_123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeServerMessage', () => {
|
||||
it('reads a welcome frame', () => {
|
||||
const buffer = new ArrayBuffer(15);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerWelcome);
|
||||
view.setUint8(1, PROTOCOL_VERSION);
|
||||
view.setUint32(2, 42, true);
|
||||
view.setUint8(6, 20);
|
||||
view.setFloat32(7, 1600, true);
|
||||
view.setFloat32(11, 900, true);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'welcome',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
playerEntityId: 42,
|
||||
tickRate: 20,
|
||||
worldWidth: 1600,
|
||||
worldHeight: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a snapshot with every entity field', () => {
|
||||
const buffer = new ArrayBuffer(7 + 21);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerSnapshot);
|
||||
view.setUint32(1, 1234, true);
|
||||
view.setUint16(5, 1, true);
|
||||
view.setUint32(7, 7, true);
|
||||
view.setUint8(11, EntityKind.Player);
|
||||
view.setFloat32(12, 100, true);
|
||||
view.setFloat32(16, 200, true);
|
||||
view.setFloat32(20, 18, true);
|
||||
view.setUint32(24, 0x4cc9f0, true);
|
||||
|
||||
const message = decodeServerMessage(buffer);
|
||||
|
||||
expect(message).toEqual({
|
||||
type: 'snapshot',
|
||||
tick: 1234,
|
||||
entities: [{ id: 7, kind: EntityKind.Player, x: 100, y: 200, radius: 18, color: 0x4cc9f0 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a pong frame', () => {
|
||||
const buffer = new ArrayBuffer(13);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPong);
|
||||
view.setBigInt64(1, 5n, true);
|
||||
view.setUint32(9, 99, true);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({ type: 'pong', clientTimeMs: 5, serverTick: 99 });
|
||||
});
|
||||
|
||||
it('ignores unknown message ids so new ones stay backwards compatible', () => {
|
||||
const buffer = new Uint8Array([0xf0, 0x00]).buffer;
|
||||
|
||||
expect(decodeServerMessage(buffer)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a truncated frame', () => {
|
||||
const buffer = new Uint8Array([MessageType.ServerWelcome, PROTOCOL_VERSION]).buffer;
|
||||
|
||||
expect(() => decodeServerMessage(buffer)).toThrow(ProtocolError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Browser side of the binary wire format.
|
||||
*
|
||||
* This file is the mirror of `src/HSchool.Protocol/ProtocolCodec.cs`; the two must be
|
||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export const MessageType = {
|
||||
ClientHello: 0x01,
|
||||
ClientInput: 0x02,
|
||||
ClientPing: 0x03,
|
||||
ServerWelcome: 0x81,
|
||||
ServerSnapshot: 0x82,
|
||||
ServerPong: 0x83,
|
||||
} as const;
|
||||
|
||||
export const InputButtons = {
|
||||
None: 0,
|
||||
Up: 1 << 0,
|
||||
Down: 1 << 1,
|
||||
Left: 1 << 2,
|
||||
Right: 1 << 3,
|
||||
} as const;
|
||||
|
||||
export const EntityKind = {
|
||||
Unknown: 0,
|
||||
Player: 1,
|
||||
Obstacle: 2,
|
||||
} as const;
|
||||
|
||||
export type EntityKindValue = (typeof EntityKind)[keyof typeof EntityKind];
|
||||
|
||||
export interface EntitySnapshot {
|
||||
readonly id: number;
|
||||
readonly kind: EntityKindValue;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly radius: number;
|
||||
/** Packed 0x00RRGGBB, ready for PixiJS. */
|
||||
readonly color: number;
|
||||
}
|
||||
|
||||
export interface WelcomeMessage {
|
||||
readonly type: 'welcome';
|
||||
readonly protocolVersion: number;
|
||||
/** Replication id of this client's own avatar. */
|
||||
readonly playerEntityId: number;
|
||||
readonly tickRate: number;
|
||||
readonly worldWidth: number;
|
||||
readonly worldHeight: number;
|
||||
}
|
||||
|
||||
export interface SnapshotMessage {
|
||||
readonly type: 'snapshot';
|
||||
readonly tick: number;
|
||||
readonly entities: readonly EntitySnapshot[];
|
||||
}
|
||||
|
||||
export interface PongMessage {
|
||||
readonly type: 'pong';
|
||||
readonly clientTimeMs: number;
|
||||
readonly serverTick: number;
|
||||
}
|
||||
|
||||
export type ServerMessage = WelcomeMessage | SnapshotMessage | PongMessage;
|
||||
|
||||
/** Thrown when a frame is truncated or carries an unexpected message id. */
|
||||
export class ProtocolError extends Error {}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function encodeHello(playerName: string): ArrayBuffer {
|
||||
const name = encoder.encode(playerName).slice(0, 32);
|
||||
const buffer = new ArrayBuffer(3 + name.length);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, MessageType.ClientHello);
|
||||
view.setUint8(1, PROTOCOL_VERSION);
|
||||
view.setUint8(2, name.length);
|
||||
new Uint8Array(buffer, 3).set(name);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function encodeInput(sequence: number, buttons: number): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(6);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, MessageType.ClientInput);
|
||||
view.setUint32(1, sequence >>> 0, true);
|
||||
view.setUint8(5, buttons & 0xff);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function encodePing(clientTimeMs: number): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(9);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, MessageType.ClientPing);
|
||||
view.setBigInt64(1, BigInt(Math.trunc(clientTimeMs)), true);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
|
||||
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
|
||||
if (data.byteLength === 0) {
|
||||
throw new ProtocolError('Empty frame.');
|
||||
}
|
||||
|
||||
const view = new DataView(data);
|
||||
const messageType = view.getUint8(0);
|
||||
|
||||
switch (messageType) {
|
||||
case MessageType.ServerWelcome:
|
||||
return decodeWelcome(view);
|
||||
case MessageType.ServerSnapshot:
|
||||
return decodeSnapshot(view);
|
||||
case MessageType.ServerPong:
|
||||
return decodePong(view);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWelcome(view: DataView): WelcomeMessage {
|
||||
ensure(view, 15);
|
||||
|
||||
return {
|
||||
type: 'welcome',
|
||||
protocolVersion: view.getUint8(1),
|
||||
playerEntityId: view.getUint32(2, true),
|
||||
tickRate: view.getUint8(6),
|
||||
worldWidth: view.getFloat32(7, true),
|
||||
worldHeight: view.getFloat32(11, true),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSnapshot(view: DataView): SnapshotMessage {
|
||||
ensure(view, 7);
|
||||
|
||||
const tick = view.getUint32(1, true);
|
||||
const count = view.getUint16(5, true);
|
||||
const entitySize = 21;
|
||||
ensure(view, 7 + count * entitySize);
|
||||
|
||||
const entities: EntitySnapshot[] = new Array(count);
|
||||
let offset = 7;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
entities[i] = {
|
||||
id: view.getUint32(offset, true),
|
||||
kind: view.getUint8(offset + 4) as EntityKindValue,
|
||||
x: view.getFloat32(offset + 5, true),
|
||||
y: view.getFloat32(offset + 9, true),
|
||||
radius: view.getFloat32(offset + 13, true),
|
||||
color: view.getUint32(offset + 17, true),
|
||||
};
|
||||
offset += entitySize;
|
||||
}
|
||||
|
||||
return { type: 'snapshot', tick, entities };
|
||||
}
|
||||
|
||||
function decodePong(view: DataView): PongMessage {
|
||||
ensure(view, 13);
|
||||
|
||||
return {
|
||||
type: 'pong',
|
||||
clientTimeMs: Number(view.getBigInt64(1, true)),
|
||||
serverTick: view.getUint32(9, true),
|
||||
};
|
||||
}
|
||||
|
||||
function ensure(view: DataView, bytes: number): void {
|
||||
if (view.byteLength < bytes) {
|
||||
throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #10141c;
|
||||
color: #d7e0ef;
|
||||
}
|
||||
|
||||
#stage canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #2a3242;
|
||||
border-radius: 8px;
|
||||
background: rgba(16, 20, 28, 0.72);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Aspire injects SERVER_HTTP / SERVER_HTTPS from the `server` resource reference,
|
||||
// so the dev server proxies to whatever port the backend actually got.
|
||||
const backend = process.env.SERVER_HTTPS ?? process.env.SERVER_HTTP ?? 'http://localhost:5180';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backend,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ws': {
|
||||
target: backend,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Tells the renderer which visual to use for a snapshot entity.</summary>
|
||||
public enum EntityKind : byte
|
||||
{
|
||||
Unknown = 0,
|
||||
Player = 1,
|
||||
Obstacle = 2,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Protocol</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Bitmask of movement intents sent by the client each input frame.</summary>
|
||||
[Flags]
|
||||
public enum InputButtons : byte
|
||||
{
|
||||
None = 0,
|
||||
Up = 1 << 0,
|
||||
Down = 1 << 1,
|
||||
Left = 1 << 2,
|
||||
Right = 1 << 3,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// First byte of every frame. Client-to-server ids live in 0x00-0x7F,
|
||||
/// server-to-client ids in 0x80-0xFF, so a misrouted frame is obvious.
|
||||
/// </summary>
|
||||
public enum MessageType : byte
|
||||
{
|
||||
None = 0x00,
|
||||
|
||||
ClientHello = 0x01,
|
||||
ClientInput = 0x02,
|
||||
ClientPing = 0x03,
|
||||
|
||||
ServerWelcome = 0x81,
|
||||
ServerSnapshot = 0x82,
|
||||
ServerPong = 0x83,
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>First frame from the client: protocol version handshake plus display name.</summary>
|
||||
public readonly record struct ClientHelloMessage(byte ProtocolVersion, string PlayerName);
|
||||
|
||||
/// <summary>
|
||||
/// Movement intent for one client frame. <paramref name="Sequence"/> is echoed back
|
||||
/// in future snapshots once client-side prediction lands.
|
||||
/// </summary>
|
||||
public readonly record struct ClientInputMessage(uint Sequence, InputButtons Buttons);
|
||||
|
||||
/// <summary>Round-trip probe; the server mirrors <paramref name="ClientTimeMs"/> back untouched.</summary>
|
||||
public readonly record struct ClientPingMessage(long ClientTimeMs);
|
||||
|
||||
/// <summary>
|
||||
/// Sent once per connection, before the first snapshot.
|
||||
/// <paramref name="PlayerEntityId"/> is the replication id of this client's own avatar,
|
||||
/// so the renderer can tell it apart from everyone else.
|
||||
/// </summary>
|
||||
public readonly record struct ServerWelcomeMessage(
|
||||
byte ProtocolVersion,
|
||||
uint PlayerEntityId,
|
||||
byte TickRate,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
|
||||
/// <summary>One entity inside a snapshot. Kept flat and blittable on purpose.</summary>
|
||||
public readonly record struct EntitySnapshot(
|
||||
uint Id,
|
||||
EntityKind Kind,
|
||||
float X,
|
||||
float Y,
|
||||
float Radius,
|
||||
uint Color);
|
||||
|
||||
/// <summary>Answer to <see cref="ClientPingMessage"/>, carrying the current server tick.</summary>
|
||||
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Little-endian cursor over a received frame. Mirror of <see cref="PacketWriter"/>.</summary>
|
||||
public ref struct PacketReader(ReadOnlySpan<byte> buffer)
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _buffer = buffer;
|
||||
private int _position = 0;
|
||||
|
||||
public readonly int Position => _position;
|
||||
|
||||
public readonly int Remaining => _buffer.Length - _position;
|
||||
|
||||
public byte ReadByte()
|
||||
{
|
||||
EnsureAvailable(sizeof(byte));
|
||||
var value = _buffer[_position];
|
||||
_position += sizeof(byte);
|
||||
return value;
|
||||
}
|
||||
|
||||
public MessageType ReadMessageType() => (MessageType)ReadByte();
|
||||
|
||||
public ushort ReadUInt16()
|
||||
{
|
||||
EnsureAvailable(sizeof(ushort));
|
||||
var value = BinaryPrimitives.ReadUInt16LittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(ushort);
|
||||
return value;
|
||||
}
|
||||
|
||||
public uint ReadUInt32()
|
||||
{
|
||||
EnsureAvailable(sizeof(uint));
|
||||
var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(uint);
|
||||
return value;
|
||||
}
|
||||
|
||||
public long ReadInt64()
|
||||
{
|
||||
EnsureAvailable(sizeof(long));
|
||||
var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(long);
|
||||
return value;
|
||||
}
|
||||
|
||||
public float ReadSingle()
|
||||
{
|
||||
EnsureAvailable(sizeof(float));
|
||||
var value = BinaryPrimitives.ReadSingleLittleEndian(_buffer[_position..]);
|
||||
_position += sizeof(float);
|
||||
return value;
|
||||
}
|
||||
|
||||
public string ReadShortString()
|
||||
{
|
||||
var byteCount = ReadByte();
|
||||
EnsureAvailable(byteCount);
|
||||
var value = Encoding.UTF8.GetString(_buffer.Slice(_position, byteCount));
|
||||
_position += byteCount;
|
||||
return value;
|
||||
}
|
||||
|
||||
private readonly void EnsureAvailable(int bytes)
|
||||
{
|
||||
if (_position + bytes > _buffer.Length)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"Truncated frame: need {bytes} bytes at offset {_position}, only {Remaining} available.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Text;
|
||||
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// Little-endian cursor over a caller-owned buffer. Little-endian matches the
|
||||
/// browser's <c>DataView</c> calls in <c>src/HSchool.Client/src/net/protocol.ts</c>.
|
||||
/// </summary>
|
||||
public ref struct PacketWriter(Span<byte> buffer)
|
||||
{
|
||||
private readonly Span<byte> _buffer = buffer;
|
||||
private int _position = 0;
|
||||
|
||||
public readonly int Position => _position;
|
||||
|
||||
public readonly ReadOnlySpan<byte> Written => _buffer[.._position];
|
||||
|
||||
public void WriteByte(byte value)
|
||||
{
|
||||
EnsureRoom(sizeof(byte));
|
||||
_buffer[_position] = value;
|
||||
_position += sizeof(byte);
|
||||
}
|
||||
|
||||
public void WriteMessageType(MessageType value) => WriteByte((byte)value);
|
||||
|
||||
public void WriteUInt16(ushort value)
|
||||
{
|
||||
EnsureRoom(sizeof(ushort));
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(ushort);
|
||||
}
|
||||
|
||||
public void WriteUInt32(uint value)
|
||||
{
|
||||
EnsureRoom(sizeof(uint));
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(uint);
|
||||
}
|
||||
|
||||
public void WriteInt64(long value)
|
||||
{
|
||||
EnsureRoom(sizeof(long));
|
||||
BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(long);
|
||||
}
|
||||
|
||||
public void WriteSingle(float value)
|
||||
{
|
||||
EnsureRoom(sizeof(float));
|
||||
BinaryPrimitives.WriteSingleLittleEndian(_buffer[_position..], value);
|
||||
_position += sizeof(float);
|
||||
}
|
||||
|
||||
/// <summary>Writes a UTF-8 string prefixed with a single length byte.</summary>
|
||||
public void WriteShortString(string value)
|
||||
{
|
||||
var byteCount = Encoding.UTF8.GetByteCount(value);
|
||||
if (byteCount > ProtocolConstants.MaxPlayerNameBytes)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"String is {byteCount} bytes, limit is {ProtocolConstants.MaxPlayerNameBytes}.");
|
||||
}
|
||||
|
||||
WriteByte((byte)byteCount);
|
||||
EnsureRoom(byteCount);
|
||||
Encoding.UTF8.GetBytes(value, _buffer[_position..]);
|
||||
_position += byteCount;
|
||||
}
|
||||
|
||||
private readonly void EnsureRoom(int bytes)
|
||||
{
|
||||
if (_position + bytes > _buffer.Length)
|
||||
{
|
||||
throw new ProtocolException(
|
||||
$"Buffer overflow: need {bytes} more bytes at offset {_position}, capacity is {_buffer.Length}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>
|
||||
/// The single place where the wire format is defined on the .NET side.
|
||||
/// Every change here must be mirrored in <c>src/HSchool.Client/src/net/protocol.ts</c>
|
||||
/// and documented in <c>docs/protocol.md</c>.
|
||||
/// </summary>
|
||||
public static class ProtocolCodec
|
||||
{
|
||||
public static int WriteHello(Span<byte> destination, in ClientHelloMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientHello);
|
||||
writer.WriteByte(message.ProtocolVersion);
|
||||
writer.WriteShortString(message.PlayerName);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WriteInput(Span<byte> destination, in ClientInputMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientInput);
|
||||
writer.WriteUInt32(message.Sequence);
|
||||
writer.WriteByte((byte)message.Buttons);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WritePing(Span<byte> destination, in ClientPingMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ClientPing);
|
||||
writer.WriteInt64(message.ClientTimeMs);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WriteWelcome(Span<byte> destination, in ServerWelcomeMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerWelcome);
|
||||
writer.WriteByte(message.ProtocolVersion);
|
||||
writer.WriteUInt32(message.PlayerEntityId);
|
||||
writer.WriteByte(message.TickRate);
|
||||
writer.WriteSingle(message.WorldWidth);
|
||||
writer.WriteSingle(message.WorldHeight);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
public static int WritePong(Span<byte> destination, in ServerPongMessage message)
|
||||
{
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerPong);
|
||||
writer.WriteInt64(message.ClientTimeMs);
|
||||
writer.WriteUInt32(message.ServerTick);
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
/// <summary>Writes a full-state snapshot; entities missing from it are despawned by the client.</summary>
|
||||
public static int WriteSnapshot(Span<byte> destination, uint tick, ReadOnlySpan<EntitySnapshot> entities)
|
||||
{
|
||||
if (entities.Length > ushort.MaxValue)
|
||||
{
|
||||
throw new ProtocolException($"Snapshot holds {entities.Length} entities, limit is {ushort.MaxValue}.");
|
||||
}
|
||||
|
||||
var writer = new PacketWriter(destination);
|
||||
writer.WriteMessageType(MessageType.ServerSnapshot);
|
||||
writer.WriteUInt32(tick);
|
||||
writer.WriteUInt16((ushort)entities.Length);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
writer.WriteUInt32(entity.Id);
|
||||
writer.WriteByte((byte)entity.Kind);
|
||||
writer.WriteSingle(entity.X);
|
||||
writer.WriteSingle(entity.Y);
|
||||
writer.WriteSingle(entity.Radius);
|
||||
writer.WriteUInt32(entity.Color);
|
||||
}
|
||||
|
||||
return writer.Position;
|
||||
}
|
||||
|
||||
/// <summary>Exact byte size of a snapshot frame for <paramref name="entityCount"/> entities.</summary>
|
||||
public static int SnapshotSize(int entityCount) =>
|
||||
ProtocolConstants.SnapshotHeaderSize + (entityCount * ProtocolConstants.EntitySnapshotSize);
|
||||
|
||||
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
|
||||
source.IsEmpty ? MessageType.None : (MessageType)source[0];
|
||||
|
||||
public static ClientHelloMessage ReadHello(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientHello);
|
||||
var version = reader.ReadByte();
|
||||
var name = reader.ReadShortString();
|
||||
return new ClientHelloMessage(version, name);
|
||||
}
|
||||
|
||||
public static ClientInputMessage ReadInput(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientInput);
|
||||
var sequence = reader.ReadUInt32();
|
||||
var buttons = (InputButtons)reader.ReadByte();
|
||||
return new ClientInputMessage(sequence, buttons);
|
||||
}
|
||||
|
||||
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ClientPing);
|
||||
return new ClientPingMessage(reader.ReadInt64());
|
||||
}
|
||||
|
||||
public static ServerWelcomeMessage ReadWelcome(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerWelcome);
|
||||
var version = reader.ReadByte();
|
||||
var playerEntityId = reader.ReadUInt32();
|
||||
var tickRate = reader.ReadByte();
|
||||
var width = reader.ReadSingle();
|
||||
var height = reader.ReadSingle();
|
||||
return new ServerWelcomeMessage(version, playerEntityId, tickRate, width, height);
|
||||
}
|
||||
|
||||
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerPong);
|
||||
var clientTime = reader.ReadInt64();
|
||||
var serverTick = reader.ReadUInt32();
|
||||
return new ServerPongMessage(clientTime, serverTick);
|
||||
}
|
||||
|
||||
/// <summary>Reads a snapshot into <paramref name="destination"/> and returns the entity count.</summary>
|
||||
public static int ReadSnapshot(ReadOnlySpan<byte> source, Span<EntitySnapshot> destination, out uint tick)
|
||||
{
|
||||
var reader = new PacketReader(source);
|
||||
Expect(ref reader, MessageType.ServerSnapshot);
|
||||
tick = reader.ReadUInt32();
|
||||
var count = reader.ReadUInt16();
|
||||
|
||||
if (count > destination.Length)
|
||||
{
|
||||
throw new ProtocolException($"Snapshot holds {count} entities, destination fits {destination.Length}.");
|
||||
}
|
||||
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
destination[i] = new EntitySnapshot(
|
||||
reader.ReadUInt32(),
|
||||
(EntityKind)reader.ReadByte(),
|
||||
reader.ReadSingle(),
|
||||
reader.ReadSingle(),
|
||||
reader.ReadSingle(),
|
||||
reader.ReadUInt32());
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void Expect(ref PacketReader reader, MessageType expected)
|
||||
{
|
||||
var actual = reader.ReadMessageType();
|
||||
if (actual != expected)
|
||||
{
|
||||
throw new ProtocolException($"Expected {expected} (0x{(byte)expected:X2}) but got 0x{(byte)actual:X2}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Wire-format constants shared by the server and the browser client.</summary>
|
||||
public static class ProtocolConstants
|
||||
{
|
||||
/// <summary>Bumped on every breaking change to the binary layout.</summary>
|
||||
public const byte Version = 1;
|
||||
|
||||
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
|
||||
public const int MaxMessageSize = 64 * 1024;
|
||||
|
||||
/// <summary>Bytes of a single entity inside a snapshot payload.</summary>
|
||||
public const int EntitySnapshotSize = sizeof(uint) + sizeof(byte) + (sizeof(float) * 3) + sizeof(uint);
|
||||
|
||||
/// <summary>Bytes of the snapshot header: message type + tick + entity count.</summary>
|
||||
public const int SnapshotHeaderSize = sizeof(byte) + sizeof(uint) + sizeof(ushort);
|
||||
|
||||
/// <summary>Maximum UTF-8 byte length of a player name.</summary>
|
||||
public const int MaxPlayerNameBytes = 32;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace HSchool.Protocol;
|
||||
|
||||
/// <summary>Thrown when a frame is truncated, oversized or otherwise unreadable.</summary>
|
||||
public sealed class ProtocolException(string message) : Exception(message);
|
||||
@@ -0,0 +1,20 @@
|
||||
using HSchool.Protocol;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Work item handed from a connection thread to the loop thread. The simulation is
|
||||
/// single-threaded, so every mutation arrives as one of these.
|
||||
/// </summary>
|
||||
internal abstract record GameCommand
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawns an avatar for the connection. The loop completes <see cref="EntityId"/>
|
||||
/// with the replication id so the handler can send a Welcome frame.
|
||||
/// </summary>
|
||||
internal sealed record Join(uint PlayerId, TaskCompletionSource<uint> EntityId) : GameCommand;
|
||||
|
||||
internal sealed record Leave(uint PlayerId) : GameCommand;
|
||||
|
||||
internal sealed record Input(uint PlayerId, InputButtons Buttons, uint Sequence) : GameCommand;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Multi-producer, single-consumer inbox drained at the start of every tick.</summary>
|
||||
internal sealed class GameCommandQueue
|
||||
{
|
||||
private readonly ConcurrentQueue<GameCommand> _commands = new();
|
||||
|
||||
public void Enqueue(GameCommand command) => _commands.Enqueue(command);
|
||||
|
||||
public bool TryDequeue(out GameCommand command) => _commands.TryDequeue(out command!);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Owns the authoritative <see cref="GameWorld"/> and drives it at a fixed rate:
|
||||
/// drain commands, step the simulation, broadcast a full snapshot.
|
||||
/// The world is touched from this thread only.
|
||||
/// </summary>
|
||||
internal sealed class GameLoopService(
|
||||
IOptions<SimulationOptions> options,
|
||||
GameCommandQueue commands,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
ILogger<GameLoopService> logger) : BackgroundService
|
||||
{
|
||||
/// <summary>Upper bound on steps simulated in one wake-up; the rest of the backlog is dropped.</summary>
|
||||
private const int MaxCatchUpSteps = 5;
|
||||
|
||||
private readonly SimulationOptions _options = options.Value;
|
||||
private readonly List<EntitySnapshot> _snapshotBuffer = [];
|
||||
private readonly GameWorld _world = new(options.Value);
|
||||
|
||||
private uint _currentTick;
|
||||
private int _playerCount;
|
||||
|
||||
public uint CurrentTick => Volatile.Read(ref _currentTick);
|
||||
|
||||
public int PlayerCount => Volatile.Read(ref _playerCount);
|
||||
|
||||
public SimulationOptions Options => _options;
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"Game loop starting at {TickRate} Hz on a {Width}x{Height} field.",
|
||||
_options.TickRate,
|
||||
_options.WorldWidth,
|
||||
_options.WorldHeight);
|
||||
|
||||
using var timer = new PeriodicTimer(_options.TickInterval);
|
||||
var fixedDelta = _options.FixedDeltaTime;
|
||||
var lastTimestamp = Stopwatch.GetTimestamp();
|
||||
var accumulator = 0d;
|
||||
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
var now = Stopwatch.GetTimestamp();
|
||||
accumulator += Stopwatch.GetElapsedTime(lastTimestamp, now).TotalSeconds;
|
||||
lastTimestamp = now;
|
||||
|
||||
DrainCommands();
|
||||
|
||||
var steps = 0;
|
||||
while (accumulator >= fixedDelta && steps < MaxCatchUpSteps)
|
||||
{
|
||||
var stepStarted = Stopwatch.GetTimestamp();
|
||||
_world.Tick();
|
||||
metrics.RecordTick(Stopwatch.GetElapsedTime(stepStarted, Stopwatch.GetTimestamp()).TotalMilliseconds);
|
||||
|
||||
accumulator -= fixedDelta;
|
||||
steps++;
|
||||
}
|
||||
|
||||
if (steps == MaxCatchUpSteps && accumulator >= fixedDelta)
|
||||
{
|
||||
logger.LogWarning("Game loop is behind by {Backlog:F0} ms; dropping the backlog.", accumulator * 1000);
|
||||
accumulator = 0d;
|
||||
}
|
||||
|
||||
if (steps > 0)
|
||||
{
|
||||
Volatile.Write(ref _currentTick, _world.CurrentTick);
|
||||
BroadcastSnapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Normal shutdown.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_world.Dispose();
|
||||
logger.LogInformation("Game loop stopped at tick {Tick}.", _world.CurrentTick);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainCommands()
|
||||
{
|
||||
while (commands.TryDequeue(out var command))
|
||||
{
|
||||
switch (command)
|
||||
{
|
||||
case GameCommand.Join join:
|
||||
HandleJoin(join);
|
||||
break;
|
||||
|
||||
case GameCommand.Leave leave:
|
||||
_world.DespawnPlayer(leave.PlayerId);
|
||||
Volatile.Write(ref _playerCount, _world.PlayerCount);
|
||||
metrics.PlayerLeft();
|
||||
logger.LogInformation("Player {PlayerId} left; {PlayerCount} remaining.", leave.PlayerId, _world.PlayerCount);
|
||||
break;
|
||||
|
||||
case GameCommand.Input input:
|
||||
_world.ApplyInput(input.PlayerId, input.Buttons, input.Sequence);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleJoin(GameCommand.Join join)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entityId = _world.SpawnPlayer(join.PlayerId);
|
||||
Volatile.Write(ref _playerCount, _world.PlayerCount);
|
||||
metrics.PlayerJoined();
|
||||
join.EntityId.TrySetResult(entityId);
|
||||
logger.LogInformation(
|
||||
"Player {PlayerId} joined as entity {EntityId}; {PlayerCount} connected.",
|
||||
join.PlayerId,
|
||||
entityId,
|
||||
_world.PlayerCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
join.EntityId.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void BroadcastSnapshot()
|
||||
{
|
||||
_world.CaptureSnapshot(_snapshotBuffer);
|
||||
|
||||
// One immutable buffer is shared by every recipient, so nothing has to be copied per client.
|
||||
var frame = new byte[ProtocolCodec.SnapshotSize(_snapshotBuffer.Count)];
|
||||
var written = ProtocolCodec.WriteSnapshot(frame, _world.CurrentTick, System.Runtime.InteropServices.CollectionsMarshal.AsSpan(_snapshotBuffer));
|
||||
|
||||
var recipients = clients.Broadcast(frame.AsMemory(0, written));
|
||||
if (recipients > 0)
|
||||
{
|
||||
metrics.SnapshotSent(written, recipients);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>Game-loop counters surfaced in the Aspire dashboard.</summary>
|
||||
internal sealed class GameMetrics : IDisposable
|
||||
{
|
||||
public const string MeterName = "HSchool.Server.Game";
|
||||
|
||||
private readonly Meter _meter;
|
||||
private readonly Counter<long> _ticks;
|
||||
private readonly Histogram<double> _tickDuration;
|
||||
private readonly UpDownCounter<long> _connectedPlayers;
|
||||
private readonly Counter<long> _snapshotBytes;
|
||||
|
||||
public GameMetrics(IMeterFactory meterFactory)
|
||||
{
|
||||
_meter = meterFactory.Create(MeterName);
|
||||
_ticks = _meter.CreateCounter<long>("hschool.game.ticks", "{tick}", "Simulation steps executed.");
|
||||
_tickDuration = _meter.CreateHistogram<double>("hschool.game.tick.duration", "ms", "Wall time of one simulation step.");
|
||||
_connectedPlayers = _meter.CreateUpDownCounter<long>("hschool.game.players", "{player}", "Currently connected players.");
|
||||
_snapshotBytes = _meter.CreateCounter<long>("hschool.game.snapshot.bytes", "By", "Snapshot bytes pushed to clients.");
|
||||
}
|
||||
|
||||
public void RecordTick(double durationMs)
|
||||
{
|
||||
_ticks.Add(1);
|
||||
_tickDuration.Record(durationMs);
|
||||
}
|
||||
|
||||
public void PlayerJoined() => _connectedPlayers.Add(1);
|
||||
|
||||
public void PlayerLeft() => _connectedPlayers.Add(-1);
|
||||
|
||||
public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
|
||||
|
||||
public void Dispose() => _meter.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Server</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
|
||||
namespace HSchool.Server.Net;
|
||||
|
||||
/// <summary>Tracks live connections and hands out player ids.</summary>
|
||||
internal sealed class ClientRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
|
||||
private uint _nextPlayerId;
|
||||
|
||||
public int Count => _clients.Count;
|
||||
|
||||
public GameClient Add(WebSocket socket)
|
||||
{
|
||||
var playerId = Interlocked.Increment(ref _nextPlayerId);
|
||||
var client = new GameClient(playerId, socket);
|
||||
_clients[playerId] = client;
|
||||
return client;
|
||||
}
|
||||
|
||||
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Queues the same frame for every client that finished its handshake; the buffer must not
|
||||
/// be reused afterwards.
|
||||
/// </summary>
|
||||
public int Broadcast(ReadOnlyMemory<byte> frame)
|
||||
{
|
||||
var recipients = 0;
|
||||
|
||||
foreach (var client in _clients.Values)
|
||||
{
|
||||
if (client.IsReady && client.TrySend(frame))
|
||||
{
|
||||
recipients++;
|
||||
}
|
||||
}
|
||||
|
||||
return recipients;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace HSchool.Server.Net;
|
||||
|
||||
/// <summary>
|
||||
/// One connected browser. Frames are queued instead of written inline so a slow client
|
||||
/// can never stall the game loop; when the outbox overflows the oldest snapshot is dropped,
|
||||
/// which is exactly what you want for state that is resent 20 times a second.
|
||||
/// </summary>
|
||||
internal sealed class GameClient(uint playerId, WebSocket socket)
|
||||
{
|
||||
private const int OutboxCapacity = 32;
|
||||
|
||||
private readonly Channel<ReadOnlyMemory<byte>> _outbox =
|
||||
Channel.CreateBounded<ReadOnlyMemory<byte>>(new BoundedChannelOptions(OutboxCapacity)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
private bool _ready;
|
||||
|
||||
public uint PlayerId { get; } = playerId;
|
||||
|
||||
public WebSocket Socket { get; } = socket;
|
||||
|
||||
public string Name { get; set; } = $"player-{playerId}";
|
||||
|
||||
/// <summary>
|
||||
/// Set once the welcome frame is out. Snapshots are only queued for ready clients, so a
|
||||
/// connection never sees world state before it knows its own entity id.
|
||||
/// </summary>
|
||||
public bool IsReady => Volatile.Read(ref _ready);
|
||||
|
||||
public void MarkReady() => Volatile.Write(ref _ready, true);
|
||||
|
||||
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
|
||||
public bool TrySend(ReadOnlyMemory<byte> frame) => _outbox.Writer.TryWrite(frame);
|
||||
|
||||
/// <summary>Pumps queued frames to the socket until cancelled or the outbox completes.</summary>
|
||||
public async Task RunSendLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await foreach (var frame in _outbox.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
if (Socket.State != WebSocketState.Open)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await Socket.SendAsync(frame, WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public void CompleteOutbox() => _outbox.Writer.TryComplete();
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
using System.Buffers;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Game;
|
||||
|
||||
namespace HSchool.Server.Net;
|
||||
|
||||
/// <summary>
|
||||
/// Drives one WebSocket connection: handshake, join, then the receive loop.
|
||||
/// Everything it learns from the wire is untrusted, so frames are validated before
|
||||
/// they reach the simulation.
|
||||
/// </summary>
|
||||
internal sealed class GameSocketHandler(
|
||||
ClientRegistry clients,
|
||||
GameCommandQueue commands,
|
||||
GameLoopService loop,
|
||||
ILogger<GameSocketHandler> logger)
|
||||
{
|
||||
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public async Task HandleAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var client = clients.Add(socket);
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(ProtocolConstants.MaxMessageSize);
|
||||
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
Task? sendLoop = null;
|
||||
var joined = false;
|
||||
|
||||
try
|
||||
{
|
||||
using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(connectionCts.Token);
|
||||
handshakeCts.CancelAfter(HandshakeTimeout);
|
||||
|
||||
var helloLength = await ReceiveFrameAsync(socket, buffer, handshakeCts.Token).ConfigureAwait(false);
|
||||
if (helloLength <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hello = ProtocolCodec.ReadHello(buffer.AsSpan(0, helloLength));
|
||||
if (hello.ProtocolVersion != ProtocolConstants.Version)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejecting client {PlayerId}: protocol v{ClientVersion}, server speaks v{ServerVersion}.",
|
||||
client.PlayerId,
|
||||
hello.ProtocolVersion,
|
||||
ProtocolConstants.Version);
|
||||
|
||||
await CloseAsync(
|
||||
socket,
|
||||
WebSocketCloseStatus.ProtocolError,
|
||||
$"Protocol v{ProtocolConstants.Version} required.",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
client.Name = SanitizeName(hello.PlayerName, client.PlayerId);
|
||||
|
||||
var join = new GameCommand.Join(
|
||||
client.PlayerId,
|
||||
new TaskCompletionSource<uint>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
commands.Enqueue(join);
|
||||
|
||||
var entityId = await join.EntityId.Task
|
||||
.WaitAsync(HandshakeTimeout, connectionCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
joined = true;
|
||||
|
||||
await SendWelcomeAsync(socket, entityId, connectionCts.Token).ConfigureAwait(false);
|
||||
client.MarkReady();
|
||||
|
||||
// From here on every outbound frame goes through the outbox, so there is
|
||||
// exactly one writer on the socket.
|
||||
sendLoop = client.RunSendLoopAsync(connectionCts.Token);
|
||||
|
||||
await ReceiveLoopAsync(client, buffer, connectionCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client went away or the host is shutting down.
|
||||
}
|
||||
catch (ProtocolException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Malformed frame from client {PlayerId}.", client.PlayerId);
|
||||
await CloseAsync(socket, WebSocketCloseStatus.InvalidPayloadData, "Malformed frame.", CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException ex)
|
||||
{
|
||||
logger.LogDebug(ex, "Connection {PlayerId} dropped.", client.PlayerId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
clients.Remove(client.PlayerId);
|
||||
client.CompleteOutbox();
|
||||
|
||||
if (joined)
|
||||
{
|
||||
commands.Enqueue(new GameCommand.Leave(client.PlayerId));
|
||||
}
|
||||
|
||||
if (sendLoop is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await sendLoop.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
|
||||
{
|
||||
// Expected while tearing the connection down.
|
||||
}
|
||||
}
|
||||
|
||||
await connectionCts.CancelAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReceiveLoopAsync(GameClient client, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var length = await ReceiveFrameAsync(client.Socket, buffer, cancellationToken).ConfigureAwait(false);
|
||||
if (length <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var frame = buffer.AsSpan(0, length);
|
||||
switch (ProtocolCodec.PeekMessageType(frame))
|
||||
{
|
||||
case MessageType.ClientInput:
|
||||
var input = ProtocolCodec.ReadInput(frame);
|
||||
commands.Enqueue(new GameCommand.Input(client.PlayerId, input.Buttons, input.Sequence));
|
||||
break;
|
||||
|
||||
case MessageType.ClientPing:
|
||||
var ping = ProtocolCodec.ReadPing(frame);
|
||||
SendPong(client, ping.ClientTimeMs);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.LogDebug(
|
||||
"Ignoring unexpected frame 0x{MessageType:X2} from client {PlayerId}.",
|
||||
frame[0],
|
||||
client.PlayerId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads one whole message. Returns 0 on close, -1 on an oversized or non-binary frame.</summary>
|
||||
private async Task<int> ReceiveFrameAsync(WebSocket socket, byte[] buffer, CancellationToken cancellationToken)
|
||||
{
|
||||
var offset = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var result = await socket
|
||||
.ReceiveAsync(new ArraySegment<byte>(buffer, offset, buffer.Length - offset), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (result.MessageType == WebSocketMessageType.Close)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (result.MessageType != WebSocketMessageType.Binary)
|
||||
{
|
||||
logger.LogDebug("Dropping non-binary frame.");
|
||||
return -1;
|
||||
}
|
||||
|
||||
offset += result.Count;
|
||||
|
||||
if (result.EndOfMessage)
|
||||
{
|
||||
return offset;
|
||||
}
|
||||
|
||||
if (offset >= buffer.Length)
|
||||
{
|
||||
logger.LogWarning("Frame exceeds {Limit} bytes; closing.", ProtocolConstants.MaxMessageSize);
|
||||
await CloseAsync(socket, WebSocketCloseStatus.MessageTooBig, "Frame too large.", cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendWelcomeAsync(WebSocket socket, uint entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
var options = loop.Options;
|
||||
var welcome = new ServerWelcomeMessage(
|
||||
ProtocolConstants.Version,
|
||||
entityId,
|
||||
(byte)options.TickRate,
|
||||
options.WorldWidth,
|
||||
options.WorldHeight);
|
||||
|
||||
var frame = new byte[32];
|
||||
var length = ProtocolCodec.WriteWelcome(frame, welcome);
|
||||
|
||||
await socket
|
||||
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void SendPong(GameClient client, long clientTimeMs)
|
||||
{
|
||||
var frame = new byte[16];
|
||||
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
|
||||
client.TrySend(frame.AsMemory(0, length));
|
||||
}
|
||||
|
||||
private static async Task CloseAsync(
|
||||
WebSocket socket,
|
||||
WebSocketCloseStatus status,
|
||||
string description,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (socket.State is WebSocketState.Open or WebSocketState.CloseReceived)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket.CloseAsync(status, description, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException)
|
||||
{
|
||||
// The peer may already be gone; nothing left to do.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Names come from the wire: strip control characters and clamp the length.</summary>
|
||||
private static string SanitizeName(string name, uint playerId)
|
||||
{
|
||||
var trimmed = name.Trim();
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return $"player-{playerId}";
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(trimmed.Length);
|
||||
foreach (var character in trimmed)
|
||||
{
|
||||
builder.Append(char.IsControl(character) ? ' ' : character);
|
||||
}
|
||||
|
||||
var sanitized = builder.ToString();
|
||||
return sanitized.Length <= ProtocolConstants.MaxPlayerNameBytes
|
||||
? sanitized
|
||||
: sanitized[..ProtocolConstants.MaxPlayerNameBytes];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Net.WebSockets;
|
||||
using HSchool.Server.Game;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
builder.Services
|
||||
.AddOptions<SimulationOptions>()
|
||||
.Bind(builder.Configuration.GetSection(SimulationOptions.SectionName))
|
||||
.Validate(options => options.TickRate is > 0 and <= 120, "Simulation:TickRate must be between 1 and 120.")
|
||||
.Validate(options => options.WorldWidth > 0 && options.WorldHeight > 0, "World size must be positive.")
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.AddSingleton<GameCommandQueue>();
|
||||
builder.Services.AddSingleton<ClientRegistry>();
|
||||
builder.Services.AddSingleton<GameMetrics>();
|
||||
builder.Services.AddSingleton<GameSocketHandler>();
|
||||
builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
|
||||
builder.Services.AddOpenTelemetry().WithMetrics(metrics => metrics.AddMeter(GameMetrics.MeterName));
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseExceptionHandler();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseWebSockets(new WebSocketOptions
|
||||
{
|
||||
KeepAliveInterval = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
|
||||
var api = app.MapGroup("/api");
|
||||
|
||||
api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) =>
|
||||
{
|
||||
var options = loop.Options;
|
||||
return new GameStatusResponse(
|
||||
loop.CurrentTick,
|
||||
options.TickRate,
|
||||
loop.PlayerCount,
|
||||
clients.Count,
|
||||
options.WorldWidth,
|
||||
options.WorldHeight);
|
||||
})
|
||||
.WithName("GetGameStatus");
|
||||
|
||||
// The realtime channel: one binary frame per protocol message, see docs/protocol.md.
|
||||
app.Map("/ws/game", async (HttpContext context, GameSocketHandler handler) =>
|
||||
{
|
||||
if (!context.WebSockets.IsWebSocketRequest)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
await context.Response.WriteAsync("This endpoint expects a WebSocket upgrade.");
|
||||
return;
|
||||
}
|
||||
|
||||
using WebSocket socket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
await handler.HandleAsync(socket, context.RequestAborted);
|
||||
});
|
||||
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
// In a published container the built client lands in wwwroot next to the server.
|
||||
app.UseFileServer();
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Snapshot of loop health for dashboards and integration tests.</summary>
|
||||
internal sealed record GameStatusResponse(
|
||||
uint Tick,
|
||||
int TickRate,
|
||||
int Players,
|
||||
int Connections,
|
||||
float WorldWidth,
|
||||
float WorldHeight);
|
||||
|
||||
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
|
||||
public partial class Program;
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7180;http://localhost:5180",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"HSchool.Server.Game": "Information"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Simulation": {
|
||||
"TickRate": 20,
|
||||
"WorldWidth": 1600,
|
||||
"WorldHeight": 900,
|
||||
"PlayerSpeed": 260,
|
||||
"PlayerRadius": 18
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using OpenTelemetry;
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Trace;
|
||||
|
||||
namespace Microsoft.Extensions.Hosting;
|
||||
|
||||
/// <summary>
|
||||
/// Common Aspire wiring: service discovery, resilience, health checks and OpenTelemetry.
|
||||
/// Referenced by every service project in the solution.
|
||||
/// See https://aka.ms/dotnet/aspire/service-defaults.
|
||||
/// </summary>
|
||||
public static class Extensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder)
|
||||
where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
builder.Services.ConfigureHttpClientDefaults(http =>
|
||||
{
|
||||
http.AddStandardResilienceHandler();
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder)
|
||||
where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics =>
|
||||
{
|
||||
metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation();
|
||||
})
|
||||
.WithTracing(tracing =>
|
||||
{
|
||||
tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
// Health probes would drown out the game traffic.
|
||||
options.Filter = context =>
|
||||
!context.Request.Path.StartsWithSegments(HealthEndpointPath)
|
||||
&& !context.Request.Path.StartsWithSegments(AlivenessEndpointPath))
|
||||
.AddHttpClientInstrumentation();
|
||||
});
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder)
|
||||
where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("self", () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
// Exposing health endpoints outside development has security implications:
|
||||
// https://aka.ms/dotnet/aspire/healthchecks
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = registration => registration.Tags.Contains("live"),
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder)
|
||||
where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.ServiceDefaults</RootNamespace>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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