Refactor project structure and update documentation. Replace PixiJS with plain DOM for UI rendering, enhance README with game features, and revise protocol documentation for HTTP API. Remove unused files and streamline client code for better maintainability.
ci / server (push) Failing after 3m31s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 12:27:30 +03:00
parent e6739e7912
commit b9ddc018d3
73 changed files with 4387 additions and 2930 deletions
+6 -8
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -7,13 +7,11 @@
<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>
<main id="app"></main>
<footer id="status">
<span data-status="connection">подключение</span>
<span data-status="ping">-- мс</span>
</footer>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
formatGameDate,
formatGameDateTime,
formatGameTimeOfDay,
formatGameWeekday,
fromDateAndTimeInputs,
toDateAndTimeInputs,
} from './gameTime.ts';
// The default start of a new school: 3 April 2012, 06:00 — a Tuesday.
const START = new Date(Date.UTC(2012, 3, 3, 6, 0, 0));
describe('game time formatting', () => {
it('shows the time of day in 24-hour form', () => {
expect(formatGameTimeOfDay(START)).toBe('06:00');
});
it('shows the weekday of the game date', () => {
expect(formatGameWeekday(START)).toBe('вторник');
});
it('shows the full date', () => {
expect(formatGameDate(START)).toContain('2012');
expect(formatGameDate(START)).toContain('апреля');
});
it('shows a compact date and time for the school cards', () => {
expect(formatGameDateTime(START)).toContain('03.04.2012');
expect(formatGameDateTime(START)).toContain('06:00');
});
it('reads the calendar in UTC, so the school day does not shift with the viewer', () => {
// Just before midnight UTC: any local-time formatting would land on the 4th.
const lateEvening = new Date(Date.UTC(2012, 3, 3, 23, 30, 0));
expect(formatGameDateTime(lateEvening)).toContain('03.04.2012');
expect(formatGameTimeOfDay(lateEvening)).toBe('23:30');
});
});
describe('date inputs', () => {
it('splits an instant into the date and time input values', () => {
expect(toDateAndTimeInputs(START)).toEqual({ date: '2012-04-03', time: '06:00' });
});
it('rebuilds the same instant from those values', () => {
const { date, time } = toDateAndTimeInputs(START);
expect(fromDateAndTimeInputs(date, time)).toEqual(START);
});
it('returns null when a field is empty', () => {
expect(fromDateAndTimeInputs('', '06:00')).toBeNull();
expect(fromDateAndTimeInputs('2012-04-03', '')).toBeNull();
});
it('returns null for an unparsable date', () => {
expect(fromDateAndTimeInputs('not-a-date', '06:00')).toBeNull();
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Formatting of the in-game calendar.
*
* The game date has no time zone: the server sends it as a UTC instant and every formatter here
* reads it back in UTC. Formatting in the viewer's local zone would shift the school day by
* whatever offset they happen to live in.
*/
const LOCALE = 'ru-RU';
const UTC = 'UTC';
const dateFormat = new Intl.DateTimeFormat(LOCALE, {
timeZone: UTC,
day: 'numeric',
month: 'long',
year: 'numeric',
});
const timeFormat = new Intl.DateTimeFormat(LOCALE, {
timeZone: UTC,
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const weekdayFormat = new Intl.DateTimeFormat(LOCALE, { timeZone: UTC, weekday: 'long' });
const shortFormat = new Intl.DateTimeFormat(LOCALE, {
timeZone: UTC,
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
/** "3 апреля 2012 г." */
export function formatGameDate(date: Date): string {
return dateFormat.format(date);
}
/** "06:00" */
export function formatGameTimeOfDay(date: Date): string {
return timeFormat.format(date);
}
/** "вторник" */
export function formatGameWeekday(date: Date): string {
return weekdayFormat.format(date);
}
/** "03.04.2012, 06:00" — the compact form the school cards use. */
export function formatGameDateTime(date: Date): string {
return shortFormat.format(date);
}
/** Splits an ISO instant into the `<input type="date">` and `<input type="time">` values it needs. */
export function toDateAndTimeInputs(date: Date): { date: string; time: string } {
const iso = date.toISOString();
return { date: iso.slice(0, 10), time: iso.slice(11, 16) };
}
/** Rebuilds a UTC instant from those two inputs; returns null when either is missing or invalid. */
export function fromDateAndTimeInputs(dateValue: string, timeValue: string): Date | null {
if (dateValue === '' || timeValue === '') {
return null;
}
const parsed = new Date(`${dateValue}T${timeValue}:00Z`);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
-35
View File
@@ -1,35 +0,0 @@
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;
}
}
}
-67
View File
@@ -1,67 +0,0 @@
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;
}
}
}
-100
View File
@@ -1,100 +0,0 @@
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();
}
}
@@ -1,46 +0,0 @@
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);
});
});
@@ -1,109 +0,0 @@
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;
}
+87 -72
View File
@@ -1,72 +1,87 @@
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();
import { GameConnection, gameSocketUrl, type ConnectionStatus } from './net/connection.ts';
import { GameScreen } from './ui/gameScreen.ts';
import { MainMenu } from './ui/mainMenu.ts';
import type { School } from './net/api.ts';
import './style.css';
const STATUS_LABELS: Record<ConnectionStatus, string> = {
connecting: 'подключение…',
connected: 'сервер на связи',
reconnecting: 'переподключение…',
closed: 'соединение закрыто',
};
/** Wires the two screens to one WebSocket connection. */
function bootstrap(): void {
const app = requireElement('#app');
const statusLabel = document.querySelector<HTMLElement>('[data-status="connection"]');
const pingLabel = document.querySelector<HTMLElement>('[data-status="ping"]');
let openSchool: School | null = null;
const menu = new MainMenu({ onOpenSchool: (school) => enterSchool(school) });
const game = new GameScreen({
onLeave: () => leaveSchool(),
onSetRunning: (running) => connection.setRunning(running),
onSetSpeed: (speedIndex) => connection.setSpeed(speedIndex),
});
const connection = new GameConnection(gameSocketUrl(), {
onStatus: (status) => {
if (statusLabel !== null) {
statusLabel.textContent = STATUS_LABELS[status];
}
},
onClock: (clock) => {
if (openSchool?.id === clock.schoolId) {
game.update(clock);
}
},
onSchoolGone: (schoolId) => {
// Deleted from another tab while we were inside it.
if (openSchool?.id === schoolId) {
showMenu();
}
},
onLatency: (rttMs) => {
if (pingLabel !== null) {
pingLabel.textContent = `${Math.round(rttMs)} мс`;
}
},
});
function enterSchool(school: School): void {
openSchool = school;
menu.stop();
app.replaceChildren(game.element);
game.show(school);
connection.openSchool(school.id);
}
function leaveSchool(): void {
connection.closeSchool();
showMenu();
}
function showMenu(): void {
openSchool = null;
app.replaceChildren(menu.element);
menu.start();
}
connection.connect();
showMenu();
window.addEventListener('beforeunload', () => connection.close());
}
function requireElement(selector: string): HTMLElement {
const element = document.querySelector<HTMLElement>(selector);
if (element === null) {
throw new Error(`${selector} is missing from index.html.`);
}
return element;
}
bootstrap();
+84
View File
@@ -0,0 +1,84 @@
/**
* HTTP side of the server: everything the main menu needs. The realtime clock arrives over the
* WebSocket instead — see `connection.ts`.
*/
export interface School {
readonly id: number;
readonly name: string;
/** ISO-8601 UTC instant; the in-game calendar carries no time zone. */
readonly gameTime: string;
readonly running: boolean;
readonly speedIndex: number;
}
export interface SchoolsResponse {
readonly maxSchools: number;
readonly defaultStartDate: string;
readonly gameMinutesPerRealSecond: number;
readonly schools: readonly School[];
}
/** A failed request, with the machine-readable `code` the server puts in its problem details. */
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
}
}
export async function fetchSchools(): Promise<SchoolsResponse> {
return request<SchoolsResponse>('/api/schools');
}
export async function fetchRandomName(): Promise<string> {
const response = await request<{ name: string }>('/api/schools/random-name');
return response.name;
}
export async function createSchool(name: string, startDate: Date): Promise<School> {
return request<School>('/api/schools', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name, startDate: startDate.toISOString() }),
});
}
export async function deleteSchool(id: number): Promise<void> {
await request<void>(`/api/schools/${id}`, { method: 'DELETE' }, { expectBody: false });
}
async function request<T>(
url: string,
init?: RequestInit,
options: { expectBody?: boolean } = {},
): Promise<T> {
const response = await fetch(url, init);
if (!response.ok) {
throw await toApiError(response);
}
if (options.expectBody === false) {
return undefined as T;
}
return (await response.json()) as T;
}
async function toApiError(response: Response): Promise<ApiError> {
try {
// ASP.NET Core problem details; `code` is added by the server for cases the UI reacts to.
const problem = (await response.json()) as { code?: string; detail?: string; title?: string };
return new ApiError(
response.status,
problem.code ?? 'unknown',
problem.detail ?? problem.title ?? response.statusText,
);
} catch {
return new ApiError(response.status, 'unknown', response.statusText);
}
}
+190 -158
View File
@@ -1,158 +1,190 @@
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}`;
}
import {
decodeServerMessage,
encodeCloseSchool,
encodeHello,
encodeOpenSchool,
encodePing,
encodeSetRunning,
encodeSetSpeed,
ProtocolError,
type ClockMessage,
type ServerMessage,
type WelcomeMessage,
} from './protocol.ts';
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'closed';
export interface ConnectionHandlers {
onStatus?(status: ConnectionStatus): void;
onWelcome?(message: WelcomeMessage): void;
onClock?(message: ClockMessage): void;
/** The open school was deleted elsewhere; the UI has to leave it. */
onSchoolGone?(schoolId: 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 the school commands.
* The UI 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 closedByUs = false;
/** Re-sent after a reconnect so the server puts us back into the same school. */
private openSchoolId: number | null = null;
constructor(
private readonly url: 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.handlers.onStatus?.('connected');
this.startPinging();
if (this.openSchoolId !== null) {
socket.send(encodeOpenSchool(this.openSchoolId));
}
});
socket.addEventListener('message', (event) => this.handleMessage(event));
socket.addEventListener('close', () => this.handleClose());
socket.addEventListener('error', () => socket.close());
}
/** Starts watching a school; its calendar starts running server-side. */
openSchool(schoolId: number): void {
this.openSchoolId = schoolId;
this.send(encodeOpenSchool(schoolId));
}
/** Back to the menu; the school stops ticking. */
closeSchool(): void {
if (this.openSchoolId === null) {
return;
}
this.openSchoolId = null;
this.send(encodeCloseSchool());
}
setRunning(running: boolean): void {
this.send(encodeSetRunning(running));
}
setSpeed(speedIndex: number): void {
this.send(encodeSetSpeed(speedIndex));
}
close(): void {
this.closedByUs = true;
this.stopTimers();
this.socket?.close();
this.socket = null;
this.handlers.onStatus?.('closed');
}
private send(frame: ArrayBuffer): void {
if (this.socket?.readyState === WebSocket.OPEN) {
this.socket.send(frame);
}
}
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 'clock':
this.handlers.onClock?.(message);
break;
case 'school-gone':
if (this.openSchoolId === message.schoolId) {
this.openSchoolId = null;
}
this.handlers.onSchoolGone?.(message.schoolId);
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(() => this.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}`;
}
+143 -116
View File
@@ -1,116 +1,143 @@
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);
});
});
import { describe, expect, it } from 'vitest';
import {
CLOCK_SPEEDS,
decodeServerMessage,
encodeCloseSchool,
encodeHello,
encodeOpenSchool,
encodePing,
encodeSetRunning,
encodeSetSpeed,
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 two-byte hello carrying the version', () => {
const view = new DataView(encodeHello());
expect(view.byteLength).toBe(2);
expect(view.getUint8(0)).toBe(MessageType.ClientHello);
expect(view.getUint8(1)).toBe(PROTOCOL_VERSION);
});
it('writes a ping frame carrying the client clock', () => {
const view = new DataView(encodePing(1_700_000_000_123));
expect(view.byteLength).toBe(9);
expect(view.getUint8(0)).toBe(MessageType.ClientPing);
expect(Number(view.getBigInt64(1, true))).toBe(1_700_000_000_123);
});
it('writes the school id little-endian', () => {
const buffer = encodeOpenSchool(0x01020304);
const view = new DataView(buffer);
expect(view.byteLength).toBe(5);
expect(view.getUint8(0)).toBe(MessageType.ClientOpenSchool);
expect([...new Uint8Array(buffer, 1)]).toEqual([0x04, 0x03, 0x02, 0x01]);
});
it('writes a single-byte close', () => {
const view = new DataView(encodeCloseSchool());
expect(view.byteLength).toBe(1);
expect(view.getUint8(0)).toBe(MessageType.ClientCloseSchool);
});
it('writes play/pause as its own frame', () => {
const view = new DataView(encodeSetRunning(true));
expect(view.byteLength).toBe(2);
expect(view.getUint8(0)).toBe(MessageType.ClientSetRunning);
expect(view.getUint8(1)).toBe(1);
});
it('writes the speed index as its own frame, carrying no running state', () => {
const view = new DataView(encodeSetSpeed(4));
expect(view.byteLength).toBe(2);
expect(view.getUint8(0)).toBe(MessageType.ClientSetSpeed);
expect(view.getUint8(1)).toBe(4);
});
});
describe('decodeServerMessage', () => {
it('reads a welcome frame', () => {
const buffer = new ArrayBuffer(4);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerWelcome);
view.setUint8(1, PROTOCOL_VERSION);
view.setUint8(2, 20);
view.setUint8(3, 6);
expect(decodeServerMessage(buffer)).toEqual({
type: 'welcome',
protocolVersion: PROTOCOL_VERSION,
tickRate: 20,
maxSchools: 6,
});
});
it('reads a clock frame as a UTC instant', () => {
// 2012-04-03T06:00:00Z
const gameTimeMs = Date.UTC(2012, 3, 3, 6, 0, 0);
const buffer = new ArrayBuffer(15);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerClock);
view.setInt32(1, 7, true);
view.setBigInt64(5, BigInt(gameTimeMs), true);
view.setUint8(13, 1);
view.setUint8(14, 2);
expect(decodeServerMessage(buffer)).toEqual({
type: 'clock',
schoolId: 7,
gameTime: new Date(gameTimeMs),
running: true,
speedIndex: 2,
});
});
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('reads a school-gone frame', () => {
const buffer = new ArrayBuffer(5);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ServerSchoolGone);
view.setInt32(1, 3, true);
expect(decodeServerMessage(buffer)).toEqual({ type: 'school-gone', schoolId: 3 });
});
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.ServerClock, 1, 2]).buffer;
expect(() => decodeServerMessage(buffer)).toThrow(ProtocolError);
});
});
describe('clock speeds', () => {
it('matches the server table', () => {
expect([...CLOCK_SPEEDS]).toEqual([0.5, 1, 2, 3, 4]);
});
});
+189 -182
View File
@@ -1,182 +1,189 @@
/**
* 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}.`);
}
}
/**
* 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 = 3;
export const MessageType = {
ClientHello: 0x01,
ClientPing: 0x02,
ClientOpenSchool: 0x03,
ClientCloseSchool: 0x04,
ClientSetRunning: 0x05,
ClientSetSpeed: 0x06,
ServerWelcome: 0x81,
ServerPong: 0x82,
ServerClock: 0x83,
ServerSchoolGone: 0x84,
} as const;
/**
* Speed buttons, in wire order — the index travels, not the multiplier.
* Mirror of `ClockSpeed.Multipliers` in `src/HSchool.Simulation/ClockSpeed.cs`.
*/
export const CLOCK_SPEEDS = [0.5, 1, 2, 3, 4] as const;
export const DEFAULT_SPEED_INDEX = 1;
export interface WelcomeMessage {
readonly type: 'welcome';
readonly protocolVersion: number;
readonly tickRate: number;
readonly maxSchools: number;
}
export interface PongMessage {
readonly type: 'pong';
readonly clientTimeMs: number;
readonly serverTick: number;
}
export interface ClockMessage {
readonly type: 'clock';
readonly schoolId: number;
/** In-game date as a UTC instant; the game calendar has no time zone. */
readonly gameTime: Date;
readonly running: boolean;
readonly speedIndex: number;
}
export interface SchoolGoneMessage {
readonly type: 'school-gone';
readonly schoolId: number;
}
export type ServerMessage = WelcomeMessage | PongMessage | ClockMessage | SchoolGoneMessage;
/** Thrown when a frame is truncated or carries an unexpected message id. */
export class ProtocolError extends Error {}
export function encodeHello(): ArrayBuffer {
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientHello);
view.setUint8(1, PROTOCOL_VERSION);
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;
}
export function encodeOpenSchool(schoolId: number): ArrayBuffer {
const buffer = new ArrayBuffer(5);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientOpenSchool);
view.setInt32(1, schoolId, true);
return buffer;
}
export function encodeCloseSchool(): ArrayBuffer {
const buffer = new ArrayBuffer(1);
new DataView(buffer).setUint8(0, MessageType.ClientCloseSchool);
return buffer;
}
/**
* Play/pause and speed are separate frames on purpose: a button that also resent the other field
* would clobber it with whatever the client last saw, which is always one tick stale.
*/
export function encodeSetRunning(running: boolean): ArrayBuffer {
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientSetRunning);
view.setUint8(1, running ? 1 : 0);
return buffer;
}
export function encodeSetSpeed(speedIndex: number): ArrayBuffer {
const buffer = new ArrayBuffer(2);
const view = new DataView(buffer);
view.setUint8(0, MessageType.ClientSetSpeed);
view.setUint8(1, speedIndex & 0xff);
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);
switch (view.getUint8(0)) {
case MessageType.ServerWelcome:
return decodeWelcome(view);
case MessageType.ServerPong:
return decodePong(view);
case MessageType.ServerClock:
return decodeClock(view);
case MessageType.ServerSchoolGone:
return decodeSchoolGone(view);
default:
return null;
}
}
function decodeWelcome(view: DataView): WelcomeMessage {
ensure(view, 4);
return {
type: 'welcome',
protocolVersion: view.getUint8(1),
tickRate: view.getUint8(2),
maxSchools: view.getUint8(3),
};
}
function decodePong(view: DataView): PongMessage {
ensure(view, 13);
return {
type: 'pong',
clientTimeMs: Number(view.getBigInt64(1, true)),
serverTick: view.getUint32(9, true),
};
}
function decodeClock(view: DataView): ClockMessage {
ensure(view, 15);
return {
type: 'clock',
schoolId: view.getInt32(1, true),
gameTime: new Date(Number(view.getBigInt64(5, true))),
running: view.getUint8(13) !== 0,
speedIndex: view.getUint8(14),
};
}
function decodeSchoolGone(view: DataView): SchoolGoneMessage {
ensure(view, 5);
return { type: 'school-gone', schoolId: view.getInt32(1, true) };
}
function ensure(view: DataView, bytes: number): void {
if (view.byteLength < bytes) {
throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`);
}
}
+269 -13
View File
@@ -1,6 +1,16 @@
:root {
color-scheme: dark;
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace;
--surface: #10141c;
--surface-raised: #171d28;
--border: #2a3242;
--text: #d7e0ef;
--text-muted: #8b98ad;
--accent: #4cc9f0;
--danger: #f2536d;
font-family: system-ui, "Segoe UI", sans-serif;
color: var(--text);
}
* {
@@ -9,26 +19,272 @@
body {
margin: 0;
overflow: hidden;
background: #10141c;
color: #d7e0ef;
min-height: 100vh;
background: var(--surface);
}
#stage canvas {
display: block;
#app {
max-width: 900px;
margin: 0 auto;
padding: 32px 20px 72px;
}
#hud {
#status {
position: fixed;
top: 12px;
left: 12px;
right: 16px;
bottom: 12px;
display: flex;
gap: 14px;
font-size: 12px;
color: var(--text-muted);
}
/* Screens */
.screen__header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 20px;
}
.screen__title {
margin: 0;
font-size: 24px;
font-weight: 600;
}
.screen__actions {
margin-left: auto;
}
.hint {
margin: 0 0 16px;
color: var(--text-muted);
font-size: 14px;
}
.hint--error {
color: var(--danger);
}
/* School cards */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface-raised);
cursor: pointer;
transition: border-color 120ms ease, transform 120ms ease;
}
.card:hover,
.card:focus-visible {
border-color: var(--accent);
transform: translateY(-2px);
outline: none;
}
.card__title {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.card__meta {
display: flex;
align-items: center;
gap: 10px;
}
.card__time {
margin: 0;
color: var(--text-muted);
font-size: 14px;
font-variant-numeric: tabular-nums;
}
.card__badge {
padding: 2px 8px;
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 12px;
}
.card__actions {
margin-top: 8px;
}
/* Clock */
.clock {
padding: 28px;
border: 1px solid var(--border);
border-radius: 16px;
background: var(--surface-raised);
text-align: center;
}
.clock__time {
margin: 0;
font-size: 56px;
font-weight: 600;
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.clock__date {
margin: 4px 0 0;
font-size: 18px;
}
.clock__weekday {
margin: 2px 0 0;
color: var(--text-muted);
text-transform: capitalize;
}
.clock__controls {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 20px;
}
/* Controls */
.button {
padding: 8px 14px;
border: 1px solid #2a3242;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--text);
font: inherit;
font-size: 14px;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.button:hover:not([disabled]) {
border-color: var(--accent);
}
.button[disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.button--primary {
border-color: var(--accent);
color: var(--accent);
}
.button--danger {
border-color: var(--danger);
color: var(--danger);
}
.button--small {
padding: 6px 10px;
font-size: 13px;
}
.button--icon {
min-width: 44px;
font-size: 16px;
}
.button--active {
border-color: var(--accent);
background: rgba(76, 201, 240, 0.16);
color: var(--accent);
}
.input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(16, 20, 28, 0.72);
background: var(--surface);
color: var(--text);
font: inherit;
font-size: 14px;
}
.input:focus {
border-color: var(--accent);
outline: none;
}
/* Dialogs */
.dialog {
min-width: 340px;
max-width: 440px;
padding: 24px;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface-raised);
color: var(--text);
}
.dialog::backdrop {
background: rgba(6, 9, 14, 0.7);
}
.dialog__title {
margin: 0 0 12px;
font-size: 19px;
}
.dialog__message {
margin: 0 0 20px;
color: var(--text-muted);
}
.dialog__error {
margin: 0;
color: var(--danger);
font-size: 14px;
}
.dialog__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.form {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field__label {
font-size: 13px;
letter-spacing: 0.02em;
pointer-events: none;
color: var(--text-muted);
}
.field__row {
display: flex;
gap: 8px;
}
@@ -0,0 +1,41 @@
import { el } from './dom.ts';
import { Modal } from './modal.ts';
interface ConfirmOptions {
readonly title: string;
readonly message: string;
readonly confirmLabel?: string;
readonly cancelLabel?: string;
/** Paints the confirm button as destructive. */
readonly danger?: boolean;
}
/** A modal question with two answers; resolves false when dismissed. */
export function confirmDialog(options: ConfirmOptions): Promise<boolean> {
const modal = new Modal(false);
const confirmButton = el('button', {
class: options.danger === true ? 'button button--danger' : 'button button--primary',
type: 'button',
text: options.confirmLabel ?? 'Подтвердить',
onClick: () => modal.close(true),
});
modal.element.append(
el('h2', { class: 'dialog__title', text: options.title }),
el('p', { class: 'dialog__message', text: options.message }),
el(
'div',
{ class: 'dialog__actions' },
el('button', {
class: 'button',
type: 'button',
text: options.cancelLabel ?? 'Отмена',
onClick: () => modal.close(false),
}),
confirmButton,
),
);
return modal.open(confirmButton);
}
@@ -0,0 +1,141 @@
import { ApiError, type School } from '../net/api.ts';
import { fromDateAndTimeInputs, toDateAndTimeInputs } from '../format/gameTime.ts';
import { el } from './dom.ts';
import { Modal } from './modal.ts';
interface CreateSchoolOptions {
/** Prefilled start of the school year, straight from the server config. */
readonly defaultStartDate: Date;
readonly suggestName: () => Promise<string>;
readonly create: (name: string, startDate: Date) => Promise<School>;
}
/**
* The creation form: a name (typed or rolled), a start date and a create button.
* Resolves with the created school, or `null` when the player backs out.
*/
export function createSchoolDialog(options: CreateSchoolOptions): Promise<School | null> {
const modal = new Modal<School | null>(null);
const defaults = toDateAndTimeInputs(options.defaultStartDate);
const nameInput = el('input', { class: 'input', type: 'text' });
nameInput.maxLength = 40;
nameInput.placeholder = 'Название школы';
nameInput.required = true;
const dateInput = el('input', { class: 'input', type: 'date' });
dateInput.value = defaults.date;
dateInput.required = true;
const timeInput = el('input', { class: 'input', type: 'time' });
timeInput.value = defaults.time;
timeInput.required = true;
const error = el('p', { class: 'dialog__error' });
error.hidden = true;
const randomButton = el('button', {
class: 'button',
type: 'button',
text: 'Случайное',
title: 'Придумать название',
});
const submitButton = el('button', { class: 'button button--primary', type: 'submit', text: 'Создать' });
const form = el(
'form',
{ class: 'form' },
el(
'label',
{ class: 'field' },
el('span', { class: 'field__label', text: 'Название' }),
el('div', { class: 'field__row' }, nameInput, randomButton),
),
el(
'div',
{ class: 'field' },
el('span', { class: 'field__label', text: 'Начало игры' }),
el('div', { class: 'field__row' }, dateInput, timeInput),
),
error,
el(
'div',
{ class: 'dialog__actions' },
el('button', { class: 'button', type: 'button', text: 'Отмена', onClick: () => modal.close(null) }),
submitButton,
),
);
let busy = false;
const setBusy = (value: boolean): void => {
busy = value;
submitButton.toggleAttribute('disabled', value);
randomButton.toggleAttribute('disabled', value);
};
const showError = (message: string): void => {
error.textContent = message;
error.hidden = false;
};
randomButton.addEventListener('click', () => {
if (busy) {
return;
}
setBusy(true);
options
.suggestName()
.then((name) => {
nameInput.value = name;
error.hidden = true;
})
.catch(() => showError('Не удалось получить название с сервера.'))
.finally(() => setBusy(false));
});
form.addEventListener('submit', (event) => {
event.preventDefault();
if (busy) {
return;
}
const startDate = fromDateAndTimeInputs(dateInput.value, timeInput.value);
if (startDate === null) {
showError('Укажите дату и время начала.');
return;
}
setBusy(true);
options
.create(nameInput.value.trim(), startDate)
.then((school) => modal.close(school))
.catch((reason: unknown) => {
showError(describe(reason));
setBusy(false);
});
});
modal.element.append(el('h2', { class: 'dialog__title', text: 'Новая школа' }), form);
return modal.open(nameInput);
}
function describe(reason: unknown): string {
if (!(reason instanceof ApiError)) {
return 'Сервер недоступен. Попробуйте ещё раз.';
}
switch (reason.code) {
case 'school-limit-reached':
return 'Достигнут лимит школ — удалите одну, чтобы создать новую.';
case 'invalid-name':
return 'Название должно быть от 1 до 40 символов.';
case 'invalid-start-date':
return 'Дата начала вне допустимого диапазона.';
default:
return reason.message;
}
}
+49
View File
@@ -0,0 +1,49 @@
/** Tiny helpers so the screens can build DOM without a framework or string templates. */
type Child = Node | string | null | undefined | false;
interface ElementOptions {
class?: string;
text?: string;
title?: string;
type?: string;
disabled?: boolean;
dataset?: Record<string, string>;
onClick?: (event: Event) => void;
}
export function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
options: ElementOptions = {},
...children: Child[]
): HTMLElementTagNameMap[K] {
const element = document.createElement(tag);
if (options.class !== undefined) element.className = options.class;
if (options.text !== undefined) element.textContent = options.text;
if (options.title !== undefined) element.title = options.title;
if (options.type !== undefined) element.setAttribute('type', options.type);
if (options.disabled !== undefined) element.toggleAttribute('disabled', options.disabled);
if (options.onClick !== undefined) element.addEventListener('click', options.onClick);
for (const [key, value] of Object.entries(options.dataset ?? {})) {
element.dataset[key] = value;
}
append(element, children);
return element;
}
export function append(parent: Node, children: Child[]): void {
for (const child of children) {
if (child === null || child === undefined || child === false) {
continue;
}
parent.appendChild(typeof child === 'string' ? document.createTextNode(child) : child);
}
}
export function clear(element: Element): void {
element.replaceChildren();
}
+88
View File
@@ -0,0 +1,88 @@
import { CLOCK_SPEEDS, type ClockMessage } from '../net/protocol.ts';
import { formatGameDate, formatGameTimeOfDay, formatGameWeekday } from '../format/gameTime.ts';
import type { School } from '../net/api.ts';
import { el } from './dom.ts';
interface GameScreenOptions {
readonly onLeave: () => void;
readonly onSetRunning: (running: boolean) => void;
readonly onSetSpeed: (speedIndex: number) => void;
}
const SPEED_LABELS = ['×½', '×1', '×2', '×3', '×4'];
/**
* The inside of a school: for now the calendar and the controls that drive it. The server owns
* the clock, so every button only sends an intent and the display follows the next clock frame.
*/
export class GameScreen {
private readonly root = el('section', { class: 'screen game' });
private readonly schoolName = el('h1', { class: 'screen__title' });
private readonly time = el('p', { class: 'clock__time', text: '--:--' });
private readonly date = el('p', { class: 'clock__date' });
private readonly weekday = el('p', { class: 'clock__weekday' });
private readonly playPauseButton = el('button', { class: 'button button--icon', type: 'button', text: '▶' });
private readonly speedButtons: HTMLButtonElement[];
private running = false;
constructor(options: GameScreenOptions) {
this.speedButtons = CLOCK_SPEEDS.map((_, index) =>
el('button', {
class: 'button button--small',
type: 'button',
text: SPEED_LABELS[index] ?? `×${CLOCK_SPEEDS[index]}`,
onClick: () => options.onSetSpeed(index),
}),
);
this.playPauseButton.addEventListener('click', () => options.onSetRunning(!this.running));
this.root.append(
el(
'header',
{ class: 'screen__header' },
el('button', { class: 'button', type: 'button', text: '← В главное меню', onClick: options.onLeave }),
this.schoolName,
),
el(
'div',
{ class: 'clock' },
this.time,
this.date,
this.weekday,
el('div', { class: 'clock__controls' }, this.playPauseButton, ...this.speedButtons),
),
el('p', { class: 'hint', text: 'Школа пока пуста — здесь появится сама игра.' }),
);
}
get element(): HTMLElement {
return this.root;
}
/** Called when the screen opens, before the first clock frame arrives. */
show(school: School): void {
this.schoolName.textContent = school.name;
this.applyClock(new Date(school.gameTime), school.running, school.speedIndex);
}
update(clock: ClockMessage): void {
this.applyClock(clock.gameTime, clock.running, clock.speedIndex);
}
private applyClock(gameTime: Date, running: boolean, speedIndex: number): void {
this.running = running;
this.time.textContent = formatGameTimeOfDay(gameTime);
this.date.textContent = formatGameDate(gameTime);
this.weekday.textContent = formatGameWeekday(gameTime);
this.playPauseButton.textContent = running ? '⏸' : '▶';
this.playPauseButton.title = running ? 'Пауза' : 'Продолжить';
this.speedButtons.forEach((button, index) => {
button.classList.toggle('button--active', index === speedIndex);
});
}
}
+192
View File
@@ -0,0 +1,192 @@
import {
createSchool,
deleteSchool,
fetchRandomName,
fetchSchools,
type School,
type SchoolsResponse,
} from '../net/api.ts';
import { el } from './dom.ts';
import { confirmDialog } from './confirmDialog.ts';
import { createSchoolDialog } from './createSchoolDialog.ts';
import { SchoolCard } from './schoolCard.ts';
interface MainMenuOptions {
readonly onOpenSchool: (school: School) => void;
}
/**
* The list of saves. Schools keep living while you are in the menu, so the list is re-read on a
* timer — the server is the only thing that knows what time it is in each of them.
*
* One second is well below the resolution the cards show: at ×1 a game minute passes every
* 12 real seconds, at ×4 every 3.
*/
const REFRESH_INTERVAL_MS = 1000;
export class MainMenu {
private readonly root = el('section', { class: 'screen menu' });
private readonly grid = el('div', { class: 'card-grid' });
private readonly emptyHint = el('p', { class: 'hint', text: 'Пока ни одной школы. Создайте первую.' });
private readonly createButton = el('button', {
class: 'button button--primary',
type: 'button',
text: 'Создать школу',
});
private readonly limitHint = el('p', { class: 'hint' });
private readonly status = el('p', { class: 'hint hint--error' });
private readonly cards = new Map<number, SchoolCard>();
private state: SchoolsResponse | null = null;
private refreshTimer: ReturnType<typeof setInterval> | null = null;
private busy = false;
constructor(private readonly options: MainMenuOptions) {
this.createButton.addEventListener('click', () => void this.openCreateDialog());
this.status.hidden = true;
this.root.append(
el(
'header',
{ class: 'screen__header' },
el('h1', { class: 'screen__title', text: 'Школы' }),
el('div', { class: 'screen__actions' }, this.createButton),
),
this.limitHint,
this.status,
this.emptyHint,
this.grid,
);
}
get element(): HTMLElement {
return this.root;
}
/** Shows the menu: loads the list once, then keeps it fresh. */
start(): void {
void this.refresh();
this.stop();
// No visibility check here: browsers already throttle timers in background tabs, and a
// hidden-tab guard silently freezes the list in embedded views that report themselves hidden.
this.refreshTimer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS);
}
/** Called when another screen takes over. */
stop(): void {
if (this.refreshTimer !== null) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
async refresh(): Promise<void> {
try {
this.state = await fetchSchools();
this.status.hidden = true;
} catch {
this.status.textContent = 'Не удалось загрузить список школ. Проверьте соединение с сервером.';
this.status.hidden = false;
return;
}
this.render();
}
private render(): void {
const state = this.state;
if (state === null) {
return;
}
const atLimit = state.schools.length >= state.maxSchools;
this.createButton.toggleAttribute('disabled', atLimit || this.busy);
this.createButton.title = atLimit ? 'Удалите одну из школ, чтобы создать новую' : '';
this.limitHint.textContent = atLimit
? `Достигнут лимит: ${state.maxSchools} ${plural(state.maxSchools)}. Удалите одну, чтобы создать новую.`
: `Школ: ${state.schools.length} из ${state.maxSchools}.`;
this.emptyHint.hidden = state.schools.length > 0;
// Patch the cards that are already on screen; only added and removed schools touch the DOM.
const seen = new Set<number>();
for (const school of state.schools) {
seen.add(school.id);
const card = this.cards.get(school.id);
if (card === undefined) {
const created = new SchoolCard(school, {
onOpen: (opened) => this.options.onOpenSchool(opened),
onDelete: (target) => void this.confirmDelete(target),
});
this.cards.set(school.id, created);
this.grid.appendChild(created.element);
} else {
card.update(school);
}
}
for (const [id, card] of this.cards) {
if (!seen.has(id)) {
card.element.remove();
this.cards.delete(id);
}
}
}
private async confirmDelete(school: School): Promise<void> {
const confirmed = await confirmDialog({
title: 'Удалить школу?',
message: `«${school.name}» будет удалена без возможности восстановления.`,
confirmLabel: 'Удалить',
danger: true,
});
if (!confirmed) {
return;
}
try {
await deleteSchool(school.id);
} catch {
this.status.textContent = `Не удалось удалить «${school.name}».`;
this.status.hidden = false;
}
await this.refresh();
}
private async openCreateDialog(): Promise<void> {
const state = this.state;
if (state === null || this.busy) {
return;
}
this.busy = true;
try {
await createSchoolDialog({
defaultStartDate: new Date(state.defaultStartDate),
suggestName: fetchRandomName,
create: createSchool,
});
} finally {
this.busy = false;
}
await this.refresh();
}
}
function plural(count: number): string {
const remainderTen = count % 10;
const remainderHundred = count % 100;
if (remainderTen === 1 && remainderHundred !== 11) return 'школа';
if (remainderTen >= 2 && remainderTen <= 4 && (remainderHundred < 12 || remainderHundred > 14)) return 'школы';
return 'школ';
}
+46
View File
@@ -0,0 +1,46 @@
import { el } from './dom.ts';
/**
* A `<dialog>` that resolves a promise when it is dismissed.
*
* Every exit resolves *explicitly* instead of listening for the `close` event: that event is not
* delivered by every engine (Chromium 148 fires only `toggle`), and a dialog whose promise never
* settles silently freezes the screen that awaited it. `cancel` is still wired up because that is
* how Escape reports itself.
*/
export class Modal<T> {
readonly element = el('dialog', { class: 'dialog' });
private readonly result: Promise<T>;
private settle: (value: T) => void = () => {};
private settled = false;
constructor(private readonly dismissedValue: T) {
this.result = new Promise<T>((resolve) => {
this.settle = resolve;
});
this.element.addEventListener('cancel', () => this.close(this.dismissedValue));
}
/** Shows the modal and returns the promise the caller awaits. */
open(focus?: HTMLElement): Promise<T> {
document.body.appendChild(this.element);
this.element.showModal();
focus?.focus();
return this.result;
}
/** Closes the modal and resolves the promise. Safe to call more than once. */
close(value: T): void {
if (this.settled) {
return;
}
this.settled = true;
this.element.close();
this.element.remove();
this.settle(value);
}
}
+65
View File
@@ -0,0 +1,65 @@
import type { School } from '../net/api.ts';
import { formatGameDateTime } from '../format/gameTime.ts';
import { el } from './dom.ts';
interface SchoolCardOptions {
readonly onOpen: (school: School) => void;
readonly onDelete: (school: School) => void;
}
/**
* One save in the main menu. The card is patched in place rather than rebuilt, because the menu
* refreshes every second and a rebuilt card would drop focus and hover mid-click.
*/
export class SchoolCard {
readonly element = el('article', { class: 'card' });
private readonly title = el('h2', { class: 'card__title' });
private readonly time = el('p', { class: 'card__time' });
private readonly pausedBadge = el('span', { class: 'card__badge', text: '⏸ на паузе' });
private school: School;
constructor(school: School, options: SchoolCardOptions) {
this.school = school;
this.element.dataset['schoolId'] = String(school.id);
this.element.tabIndex = 0;
this.element.append(
this.title,
el('div', { class: 'card__meta' }, this.time, this.pausedBadge),
el(
'div',
{ class: 'card__actions' },
el('button', {
class: 'button button--danger button--small',
type: 'button',
text: 'Удалить',
onClick: (event) => {
// The whole card is clickable, so the delete button must not open the school too.
event.stopPropagation();
options.onDelete(this.school);
},
}),
),
);
this.element.addEventListener('click', () => options.onOpen(this.school));
this.element.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
options.onOpen(this.school);
}
});
this.update(school);
}
update(school: School): void {
this.school = school;
this.title.textContent = school.name;
this.time.textContent = formatGameDateTime(new Date(school.gameTime));
this.pausedBadge.hidden = school.running;
}
}
-9
View File
@@ -1,9 +0,0 @@
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,
}
-12
View File
@@ -1,12 +0,0 @@
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,
}
+22 -18
View File
@@ -1,18 +1,22 @@
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,
}
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,
ClientPing = 0x02,
ClientOpenSchool = 0x03,
ClientCloseSchool = 0x04,
ClientSetRunning = 0x05,
ClientSetSpeed = 0x06,
ServerWelcome = 0x81,
ServerPong = 0x82,
ServerClock = 0x83,
ServerSchoolGone = 0x84,
}
+29 -27
View File
@@ -1,37 +1,39 @@
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>First frame from the client; carries nothing but the version handshake.</summary>
public readonly record struct ClientHelloMessage(byte ProtocolVersion);
/// <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>Asks for clock updates of one school. Starts its calendar running.</summary>
public readonly record struct ClientOpenSchoolMessage(int SchoolId);
/// <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>
/// Play or pause the open school. Running and speed are separate messages on purpose: a button
/// that also resent the other field would clobber it with whatever the client last saw.
/// </summary>
public readonly record struct ClientSetRunningMessage(bool Running);
/// <summary>Change the speed of the open school without touching whether it runs.</summary>
public readonly record struct ClientSetSpeedMessage(byte SpeedIndex);
/// <summary>Sent once per connection, before anything else.</summary>
public readonly record struct ServerWelcomeMessage(byte ProtocolVersion, byte TickRate, byte MaxSchools);
/// <summary>Answer to <see cref="ClientPingMessage"/>, carrying the current server tick.</summary>
public readonly record struct ServerPongMessage(long ClientTimeMs, uint ServerTick);
/// <summary>
/// State of the open school's calendar, sent every tick.
/// <paramref name="GameTimeUnixMs"/> is the in-game date as milliseconds since the Unix epoch,
/// interpreted as UTC — the game calendar has no time zone.
/// </summary>
public readonly record struct ServerClockMessage(
int SchoolId,
long GameTimeUnixMs,
bool Running,
byte SpeedIndex);
/// <summary>The open school no longer exists (deleted from another tab); the client returns to the menu.</summary>
public readonly record struct ServerSchoolGoneMessage(int SchoolId);
+57 -75
View File
@@ -1,75 +1,57 @@
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.");
}
}
}
using System.Buffers.Binary;
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 uint ReadUInt32()
{
EnsureAvailable(sizeof(uint));
var value = BinaryPrimitives.ReadUInt32LittleEndian(_buffer[_position..]);
_position += sizeof(uint);
return value;
}
public int ReadInt32()
{
EnsureAvailable(sizeof(int));
var value = BinaryPrimitives.ReadInt32LittleEndian(_buffer[_position..]);
_position += sizeof(int);
return value;
}
public long ReadInt64()
{
EnsureAvailable(sizeof(long));
var value = BinaryPrimitives.ReadInt64LittleEndian(_buffer[_position..]);
_position += sizeof(long);
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.");
}
}
}
+54 -80
View File
@@ -1,80 +1,54 @@
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}.");
}
}
}
using System.Buffers.Binary;
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 void WriteByte(byte value)
{
EnsureRoom(sizeof(byte));
_buffer[_position] = value;
_position += sizeof(byte);
}
public void WriteMessageType(MessageType value) => WriteByte((byte)value);
public void WriteUInt32(uint value)
{
EnsureRoom(sizeof(uint));
BinaryPrimitives.WriteUInt32LittleEndian(_buffer[_position..], value);
_position += sizeof(uint);
}
public void WriteInt32(int value)
{
EnsureRoom(sizeof(int));
BinaryPrimitives.WriteInt32LittleEndian(_buffer[_position..], value);
_position += sizeof(int);
}
public void WriteInt64(long value)
{
EnsureRoom(sizeof(long));
BinaryPrimitives.WriteInt64LittleEndian(_buffer[_position..], value);
_position += sizeof(long);
}
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}.");
}
}
}
+85 -75
View File
@@ -7,21 +7,14 @@ namespace HSchool.Protocol;
/// </summary>
public static class ProtocolCodec
{
/// <summary>Largest frame this codec produces; handlers can size their buffers from it.</summary>
public const int MaxFrameSize = 16;
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;
}
@@ -33,15 +26,44 @@ public static class ProtocolCodec
return writer.Position;
}
public static int WriteOpenSchool(Span<byte> destination, in ClientOpenSchoolMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientOpenSchool);
writer.WriteInt32(message.SchoolId);
return writer.Position;
}
public static int WriteCloseSchool(Span<byte> destination)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientCloseSchool);
return writer.Position;
}
public static int WriteSetRunning(Span<byte> destination, in ClientSetRunningMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSetRunning);
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
return writer.Position;
}
public static int WriteSetSpeed(Span<byte> destination, in ClientSetSpeedMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ClientSetSpeed);
writer.WriteByte(message.SpeedIndex);
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);
writer.WriteByte(message.MaxSchools);
return writer.Position;
}
@@ -54,35 +76,24 @@ public static class ProtocolCodec
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)
public static int WriteClock(Span<byte> destination, in ServerClockMessage message)
{
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);
}
writer.WriteMessageType(MessageType.ServerClock);
writer.WriteInt32(message.SchoolId);
writer.WriteInt64(message.GameTimeUnixMs);
writer.WriteByte(message.Running ? (byte)1 : (byte)0);
writer.WriteByte(message.SpeedIndex);
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 int WriteSchoolGone(Span<byte> destination, in ServerSchoolGoneMessage message)
{
var writer = new PacketWriter(destination);
writer.WriteMessageType(MessageType.ServerSchoolGone);
writer.WriteInt32(message.SchoolId);
return writer.Position;
}
public static MessageType PeekMessageType(ReadOnlySpan<byte> source) =>
source.IsEmpty ? MessageType.None : (MessageType)source[0];
@@ -91,18 +102,7 @@ public static class ProtocolCodec
{
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);
return new ClientHelloMessage(reader.ReadByte());
}
public static ClientPingMessage ReadPing(ReadOnlySpan<byte> source)
@@ -112,16 +112,35 @@ public static class ProtocolCodec
return new ClientPingMessage(reader.ReadInt64());
}
public static ClientOpenSchoolMessage ReadOpenSchool(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientOpenSchool);
return new ClientOpenSchoolMessage(reader.ReadInt32());
}
public static ClientSetRunningMessage ReadSetRunning(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetRunning);
return new ClientSetRunningMessage(reader.ReadByte() != 0);
}
public static ClientSetSpeedMessage ReadSetSpeed(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ClientSetSpeed);
return new ClientSetSpeedMessage(reader.ReadByte());
}
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);
var maxSchools = reader.ReadByte();
return new ServerWelcomeMessage(version, tickRate, maxSchools);
}
public static ServerPongMessage ReadPong(ReadOnlySpan<byte> source)
@@ -133,31 +152,22 @@ public static class ProtocolCodec
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)
public static ServerClockMessage ReadClock(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSnapshot);
tick = reader.ReadUInt32();
var count = reader.ReadUInt16();
Expect(ref reader, MessageType.ServerClock);
var schoolId = reader.ReadInt32();
var gameTime = reader.ReadInt64();
var running = reader.ReadByte() != 0;
var speedIndex = reader.ReadByte();
return new ServerClockMessage(schoolId, gameTime, running, speedIndex);
}
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;
public static ServerSchoolGoneMessage ReadSchoolGone(ReadOnlySpan<byte> source)
{
var reader = new PacketReader(source);
Expect(ref reader, MessageType.ServerSchoolGone);
return new ServerSchoolGoneMessage(reader.ReadInt32());
}
private static void Expect(ref PacketReader reader, MessageType expected)
+11 -20
View File
@@ -1,20 +1,11 @@
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;
}
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 = 3;
/// <summary>Upper bound for a single WebSocket frame accepted by the server.</summary>
public const int MaxMessageSize = 8 * 1024;
}
+111
View File
@@ -0,0 +1,111 @@
using HSchool.Server.Game;
using HSchool.Simulation;
namespace HSchool.Server.Api;
/// <summary>
/// The main menu talks to these: list, create, delete. Everything that mutates state is handed to
/// the loop thread as a command and awaited, so schools stay single-threaded.
/// </summary>
internal static class SchoolEndpoints
{
/// <summary>How long a request waits for the loop thread before giving up.</summary>
private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(5);
public static void MapSchoolEndpoints(this IEndpointRouteBuilder builder)
{
var schools = builder.MapGroup("/api/schools");
schools.MapGet("/", (GameLoopService loop) =>
{
var state = loop.SchoolsState;
var options = loop.Options;
return new SchoolsResponse(
state.MaxSchools,
options.DefaultStartDate,
options.GameMinutesPerRealSecond,
[.. state.Schools.Select(SchoolResponse.From)]);
})
.WithName("GetSchools");
schools.MapGet("/random-name", async (GameCommandQueue commands, CancellationToken cancellationToken) =>
{
var command = new GameCommand.SuggestName(NewCompletion<string>());
commands.Enqueue(command);
var name = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return new RandomNameResponse(name);
})
.WithName("GetRandomSchoolName");
schools.MapPost("/", async (
CreateSchoolRequest request,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.CreateSchool(
request.Name ?? string.Empty,
DateTime.SpecifyKind(request.StartDate, DateTimeKind.Utc),
NewCompletion<SchoolCreationOutcome>());
commands.Enqueue(command);
var outcome = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return outcome.Error switch
{
SchoolCreationError.None =>
Results.Created($"/api/schools/{outcome.School!.Id}", SchoolResponse.From(outcome.School)),
SchoolCreationError.LimitReached =>
Problem(StatusCodes.Status409Conflict, "school-limit-reached", "The school limit is already reached."),
SchoolCreationError.InvalidName =>
Problem(StatusCodes.Status400BadRequest, "invalid-name", $"A name must be 1 to {School.MaxNameLength} characters."),
SchoolCreationError.InvalidStartDate =>
Problem(StatusCodes.Status400BadRequest, "invalid-start-date", "The start date is outside the supported range."),
_ => Results.Problem("Unknown error."),
};
})
.WithName("CreateSchool");
schools.MapDelete("/{id:int}", async (
int id,
GameCommandQueue commands,
CancellationToken cancellationToken) =>
{
var command = new GameCommand.DeleteSchool(id, NewCompletion<bool>());
commands.Enqueue(command);
var deleted = await command.Result.Task.WaitAsync(CommandTimeout, cancellationToken);
return deleted ? Results.NoContent() : Results.NotFound();
})
.WithName("DeleteSchool");
}
/// <summary>The loop thread must never be blocked by a continuation of a waiting request.</summary>
private static TaskCompletionSource<T> NewCompletion<T>() =>
new(TaskCreationOptions.RunContinuationsAsynchronously);
private static IResult Problem(int statusCode, string code, string detail) =>
Results.Problem(detail: detail, statusCode: statusCode, title: code, extensions: new Dictionary<string, object?>
{
["code"] = code,
});
}
/// <summary>Body of <c>POST /api/schools</c>. The start date is a game calendar date, not a real one.</summary>
internal sealed record CreateSchoolRequest(string? Name, DateTime StartDate);
internal sealed record SchoolResponse(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex)
{
public static SchoolResponse From(SchoolState school) =>
new(school.Id, school.Name, school.GameTime, school.Running, school.SpeedIndex);
}
/// <summary>Everything the main menu needs in one request.</summary>
internal sealed record SchoolsResponse(
int MaxSchools,
DateTime DefaultStartDate,
double GameMinutesPerRealSecond,
IReadOnlyList<SchoolResponse> Schools);
internal sealed record RandomNameResponse(string Name);
+30 -20
View File
@@ -1,20 +1,30 @@
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;
}
namespace HSchool.Server.Game;
/// <summary>
/// Work item handed from a request or connection thread to the loop thread. Schools are
/// single-threaded, so every mutation and every read of live state arrives as one of these.
/// </summary>
internal abstract record GameCommand
{
internal sealed record CreateSchool(
string Name,
DateTime StartDate,
TaskCompletionSource<SchoolCreationOutcome> Result) : GameCommand;
internal sealed record DeleteSchool(int SchoolId, TaskCompletionSource<bool> Result) : GameCommand;
internal sealed record SuggestName(TaskCompletionSource<string> Result) : GameCommand;
/// <summary>A connection starts watching a school; its calendar starts running.</summary>
internal sealed record OpenSchool(uint PlayerId, int SchoolId) : GameCommand;
/// <summary>
/// Stops watching. The school id travels with the command because the connection may already
/// be gone from the registry by the time the loop thread gets here.
/// </summary>
internal sealed record CloseSchool(uint PlayerId, int SchoolId) : GameCommand;
internal sealed record SetRunning(uint PlayerId, bool Running) : GameCommand;
internal sealed record SetSpeed(uint PlayerId, byte SpeedIndex) : GameCommand;
}
+287 -153
View File
@@ -1,153 +1,287 @@
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);
}
}
}
using System.Diagnostics;
using HSchool.Protocol;
using HSchool.Server.Net;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>
/// Owns every <see cref="School"/> and drives them at a fixed rate: drain commands, advance the
/// running calendars, push a clock frame to each connection that has a school open.
/// Schools are 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 SchoolRegistry _schools = new(options.Value);
private uint _currentTick;
private SchoolsState _publishedState = new(options.Value.MaxSchools, []);
public uint CurrentTick => Volatile.Read(ref _currentTick);
public SimulationOptions Options => _options;
/// <summary>
/// Last state published by the loop thread. Menu requests read this instead of blocking on a
/// command; it is at most one tick (50 ms) behind.
/// </summary>
public SchoolsState SchoolsState => Volatile.Read(ref _publishedState);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation(
"Game loop starting at {TickRate} Hz, up to {MaxSchools} schools, {GameMinutes} game minutes per second.",
_options.TickRate,
_options.MaxSchools,
_options.GameMinutesPerRealSecond);
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();
_schools.Tick();
Volatile.Write(ref _currentTick, _currentTick + 1);
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)
{
PublishState();
BroadcastClocks();
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown.
}
finally
{
_schools.Dispose();
logger.LogInformation("Game loop stopped at tick {Tick}.", _currentTick);
}
}
private void DrainCommands()
{
while (commands.TryDequeue(out var command))
{
switch (command)
{
case GameCommand.CreateSchool create:
HandleCreate(create);
break;
case GameCommand.DeleteSchool delete:
HandleDelete(delete);
break;
case GameCommand.SuggestName suggest:
Complete(suggest.Result, _schools.SuggestName);
break;
case GameCommand.OpenSchool open:
HandleOpen(open);
break;
case GameCommand.CloseSchool close:
StopWatching(close.PlayerId, close.SchoolId);
break;
case GameCommand.SetRunning setRunning:
WithOpenSchool(setRunning.PlayerId, school => school.Clock.IsRunning = setRunning.Running);
break;
case GameCommand.SetSpeed setSpeed:
WithOpenSchool(setSpeed.PlayerId, school => school.Clock.SpeedIndex = setSpeed.SpeedIndex);
break;
}
}
}
private void HandleCreate(GameCommand.CreateSchool command)
{
Complete(command.Result, () =>
{
var result = _schools.Create(command.Name, command.StartDate);
if (!result.Succeeded)
{
return new SchoolCreationOutcome(null, result.Error);
}
logger.LogInformation("School {SchoolId} \"{Name}\" created.", result.School!.Id, result.School.Name);
PublishState();
return new SchoolCreationOutcome(Capture(result.School), SchoolCreationError.None);
});
}
private void HandleDelete(GameCommand.DeleteSchool command)
{
Complete(command.Result, () =>
{
var deleted = _schools.Delete(command.SchoolId);
if (!deleted)
{
return false;
}
// Anyone watching it now stares at a school that no longer exists.
foreach (var client in clients.All)
{
if (client.OpenSchoolId == command.SchoolId)
{
client.OpenSchoolId = null;
SendSchoolGone(client, command.SchoolId);
}
}
logger.LogInformation("School {SchoolId} deleted.", command.SchoolId);
PublishState();
return true;
});
}
private void HandleOpen(GameCommand.OpenSchool command)
{
var client = clients.Find(command.PlayerId);
if (client is null)
{
return;
}
var school = _schools.Find(command.SchoolId);
if (school is null)
{
SendSchoolGone(client, command.SchoolId);
return;
}
client.OpenSchoolId = school.Id;
logger.LogInformation("Client {PlayerId} opened school {SchoolId}.", command.PlayerId, school.Id);
}
/// <summary>
/// The connection stops receiving clock frames for that school. The calendar keeps running —
/// schools live whether or not somebody is looking at them.
/// </summary>
private void StopWatching(uint playerId, int schoolId)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId == schoolId)
{
client.OpenSchoolId = null;
}
}
/// <summary>Applies a change to the school a connection has open, if it still has one.</summary>
private void WithOpenSchool(uint playerId, Action<School> change)
{
var client = clients.Find(playerId);
if (client?.OpenSchoolId is not { } schoolId)
{
return;
}
var school = _schools.Find(schoolId);
if (school is not null)
{
change(school);
}
}
private void BroadcastClocks()
{
foreach (var client in clients.All)
{
if (!client.IsReady || client.OpenSchoolId is not { } schoolId)
{
continue;
}
var school = _schools.Find(schoolId);
if (school is null)
{
continue;
}
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteClock(frame, new ServerClockMessage(
school.Id,
new DateTimeOffset(school.Clock.Time).ToUnixTimeMilliseconds(),
school.Clock.IsRunning,
(byte)school.Clock.SpeedIndex));
client.TrySend(frame.AsMemory(0, length));
}
}
private void SendSchoolGone(GameClient client, int schoolId)
{
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteSchoolGone(frame, new ServerSchoolGoneMessage(schoolId));
client.TrySend(frame.AsMemory(0, length));
}
private void PublishState()
{
var snapshot = new SchoolsState(
_schools.MaxSchools,
_schools.Schools.Select(Capture).ToArray());
Volatile.Write(ref _publishedState, snapshot);
metrics.SchoolsChanged(snapshot.Schools.Count);
}
private static SchoolState Capture(School school) =>
new(school.Id, school.Name, school.Clock.Time, school.Clock.IsRunning, (byte)school.Clock.SpeedIndex);
/// <summary>Runs work for a waiting request thread without letting an exception kill the loop.</summary>
private static void Complete<T>(TaskCompletionSource<T> completion, Func<T> work)
{
try
{
completion.TrySetResult(work());
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
}
}
+8 -7
View File
@@ -10,16 +10,17 @@ internal sealed class GameMetrics : IDisposable
private readonly Meter _meter;
private readonly Counter<long> _ticks;
private readonly Histogram<double> _tickDuration;
private readonly UpDownCounter<long> _connectedPlayers;
private readonly Counter<long> _snapshotBytes;
private readonly UpDownCounter<long> _connections;
private int _schools;
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.");
_connections = _meter.CreateUpDownCounter<long>("hschool.game.connections", "{connection}", "Open WebSocket connections.");
_meter.CreateObservableGauge("hschool.game.schools", () => Volatile.Read(ref _schools), "{school}", "Schools that currently exist.");
}
public void RecordTick(double durationMs)
@@ -28,11 +29,11 @@ internal sealed class GameMetrics : IDisposable
_tickDuration.Record(durationMs);
}
public void PlayerJoined() => _connectedPlayers.Add(1);
public void ClientConnected() => _connections.Add(1);
public void PlayerLeft() => _connectedPlayers.Add(-1);
public void ClientDisconnected() => _connections.Add(-1);
public void SnapshotSent(int bytes, int recipients) => _snapshotBytes.Add((long)bytes * recipients);
public void SchoolsChanged(int count) => Volatile.Write(ref _schools, count);
public void Dispose() => _meter.Dispose();
}
@@ -0,0 +1,9 @@
using HSchool.Simulation;
namespace HSchool.Server.Game;
/// <summary>What the loop thread reports back after trying to create a school.</summary>
internal readonly record struct SchoolCreationOutcome(SchoolState? School, SchoolCreationError Error)
{
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
}
+10
View File
@@ -0,0 +1,10 @@
namespace HSchool.Server.Game;
/// <summary>
/// Immutable copy of a school, safe to hand to request threads. The live <c>School</c> object
/// never leaves the loop thread.
/// </summary>
internal sealed record SchoolState(int Id, string Name, DateTime GameTime, bool Running, byte SpeedIndex);
/// <summary>Everything the main menu needs in one read.</summary>
internal sealed record SchoolsState(int MaxSchools, IReadOnlyList<SchoolState> Schools);
+17 -16
View File
@@ -1,16 +1,17 @@
<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>
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<RootNamespace>HSchool.Server</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
</ItemGroup>
</Project>
+28 -42
View File
@@ -1,42 +1,28 @@
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;
}
}
using System.Collections.Concurrent;
using System.Net.WebSockets;
namespace HSchool.Server.Net;
/// <summary>Tracks live connections and hands out client ids.</summary>
internal sealed class ClientRegistry
{
private readonly ConcurrentDictionary<uint, GameClient> _clients = new();
private uint _nextPlayerId;
public int Count => _clients.Count;
/// <summary>Snapshot-free enumeration; safe because the dictionary is concurrent.</summary>
public IEnumerable<GameClient> All => _clients.Values;
public GameClient Add(WebSocket socket)
{
var playerId = Interlocked.Increment(ref _nextPlayerId);
var client = new GameClient(playerId, socket);
_clients[playerId] = client;
return client;
}
public GameClient? Find(uint playerId) => _clients.GetValueOrDefault(playerId);
public void Remove(uint playerId) => _clients.TryRemove(playerId, out _);
}
+21 -7
View File
@@ -4,9 +4,9 @@ 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.
/// 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 frame is dropped, which is right for
/// a clock that is resent 20 times a second.
/// </summary>
internal sealed class GameClient(uint playerId, WebSocket socket)
{
@@ -21,19 +21,33 @@ internal sealed class GameClient(uint playerId, WebSocket socket)
});
private bool _ready;
private int _openSchoolId;
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.
/// Set once the welcome frame is out. Clock frames are only queued for ready clients, so a
/// connection never sees game state before the handshake finished.
/// </summary>
public bool IsReady => Volatile.Read(ref _ready);
/// <summary>
/// School this connection is watching, or <c>null</c> in the menu. Written by the loop thread,
/// read by the connection thread on disconnect.
/// </summary>
public int? OpenSchoolId
{
get
{
// School ids start at 1, so 0 stands for "this client is in the menu".
var id = Volatile.Read(ref _openSchoolId);
return id == 0 ? null : id;
}
set => Volatile.Write(ref _openSchoolId, value ?? 0);
}
public void MarkReady() => Volatile.Write(ref _ready, true);
/// <summary>Queues a frame. Returns false once the connection is shutting down.</summary>
+40 -58
View File
@@ -1,20 +1,20 @@
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.
/// Drives one WebSocket connection: version handshake, then the receive loop that turns frames
/// into commands. Everything it reads from the wire is untrusted, so frames are validated before
/// anything reaches the loop thread.
/// </summary>
internal sealed class GameSocketHandler(
ClientRegistry clients,
GameCommandQueue commands,
GameLoopService loop,
GameMetrics metrics,
ILogger<GameSocketHandler> logger)
{
private static readonly TimeSpan HandshakeTimeout = TimeSpan.FromSeconds(5);
@@ -26,7 +26,7 @@ internal sealed class GameSocketHandler(
using var connectionCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
Task? sendLoop = null;
var joined = false;
metrics.ClientConnected();
try
{
@@ -56,19 +56,7 @@ internal sealed class GameSocketHandler(
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);
await SendWelcomeAsync(socket, connectionCts.Token).ConfigureAwait(false);
client.MarkReady();
// From here on every outbound frame goes through the outbox, so there is
@@ -96,10 +84,13 @@ internal sealed class GameSocketHandler(
ArrayPool<byte>.Shared.Return(buffer);
clients.Remove(client.PlayerId);
client.CompleteOutbox();
metrics.ClientDisconnected();
if (joined)
// Read after the removal above: an open that lands later finds no client and is
// dropped, so this is the last chance to see the school this connection was watching.
if (client.OpenSchoolId is { } watchedSchoolId)
{
commands.Enqueue(new GameCommand.Leave(client.PlayerId));
commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, watchedSchoolId));
}
if (sendLoop is not null)
@@ -131,14 +122,31 @@ internal sealed class GameSocketHandler(
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));
case MessageType.ClientPing:
SendPong(client, ProtocolCodec.ReadPing(frame).ClientTimeMs);
break;
case MessageType.ClientPing:
var ping = ProtocolCodec.ReadPing(frame);
SendPong(client, ping.ClientTimeMs);
case MessageType.ClientOpenSchool:
var open = ProtocolCodec.ReadOpenSchool(frame);
commands.Enqueue(new GameCommand.OpenSchool(client.PlayerId, open.SchoolId));
break;
case MessageType.ClientCloseSchool:
if (client.OpenSchoolId is { } openSchoolId)
{
commands.Enqueue(new GameCommand.CloseSchool(client.PlayerId, openSchoolId));
}
break;
case MessageType.ClientSetRunning:
var setRunning = ProtocolCodec.ReadSetRunning(frame);
commands.Enqueue(new GameCommand.SetRunning(client.PlayerId, setRunning.Running));
break;
case MessageType.ClientSetSpeed:
var setSpeed = ProtocolCodec.ReadSetSpeed(frame);
commands.Enqueue(new GameCommand.SetSpeed(client.PlayerId, setSpeed.SpeedIndex));
break;
default:
@@ -190,18 +198,13 @@ internal sealed class GameSocketHandler(
}
}
private async Task SendWelcomeAsync(WebSocket socket, uint entityId, CancellationToken cancellationToken)
private async Task SendWelcomeAsync(WebSocket socket, 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);
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WriteWelcome(
frame,
new ServerWelcomeMessage(ProtocolConstants.Version, (byte)options.TickRate, (byte)options.MaxSchools));
await socket
.SendAsync(frame.AsMemory(0, length), WebSocketMessageType.Binary, endOfMessage: true, cancellationToken)
@@ -210,7 +213,7 @@ internal sealed class GameSocketHandler(
private void SendPong(GameClient client, long clientTimeMs)
{
var frame = new byte[16];
var frame = new byte[ProtocolCodec.MaxFrameSize];
var length = ProtocolCodec.WritePong(frame, new ServerPongMessage(clientTimeMs, loop.CurrentTick));
client.TrySend(frame.AsMemory(0, length));
}
@@ -233,25 +236,4 @@ internal sealed class GameSocketHandler(
}
}
}
/// <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];
}
}
+10 -19
View File
@@ -1,4 +1,5 @@
using System.Net.WebSockets;
using HSchool.Server.Api;
using HSchool.Server.Game;
using HSchool.Server.Net;
using HSchool.Simulation;
@@ -13,7 +14,9 @@ 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.")
.Validate(options => options.MaxSchools is > 0 and <= 255, "Simulation:MaxSchools must be between 1 and 255.")
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
.ValidateOnStart();
builder.Services.AddSingleton<GameCommandQueue>();
@@ -39,18 +42,12 @@ app.UseWebSockets(new WebSocketOptions
KeepAliveInterval = TimeSpan.FromSeconds(30),
});
var api = app.MapGroup("/api");
app.MapSchoolEndpoints();
api.MapGet("/status", (GameLoopService loop, ClientRegistry clients) =>
app.MapGet("/api/status", (GameLoopService loop, ClientRegistry clients) =>
{
var options = loop.Options;
return new GameStatusResponse(
loop.CurrentTick,
options.TickRate,
loop.PlayerCount,
clients.Count,
options.WorldWidth,
options.WorldHeight);
var state = loop.SchoolsState;
return new GameStatusResponse(loop.CurrentTick, loop.Options.TickRate, state.Schools.Count, state.MaxSchools, clients.Count);
})
.WithName("GetGameStatus");
@@ -75,14 +72,8 @@ 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>Loop health for dashboards and integration tests.</summary>
internal sealed record GameStatusResponse(uint Tick, int TickRate, int Schools, int MaxSchools, int Connections);
/// <summary>Exposed so <c>WebApplicationFactory</c>-style tests can reference the entry point.</summary>
public partial class Program;
+15 -16
View File
@@ -1,16 +1,15 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"WorldWidth": 1600,
"WorldHeight": 900,
"PlayerSpeed": 260,
"PlayerRadius": 18
}
}
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Simulation": {
"TickRate": 20,
"MaxSchools": 6,
"GameMinutesPerRealSecond": 5,
"DefaultStartDate": "2012-04-03T06:00:00"
}
}
+21
View File
@@ -0,0 +1,21 @@
namespace HSchool.Simulation;
/// <summary>
/// The speed buttons the player can pick, as an index on the wire. The table is duplicated in
/// <c>src/HSchool.Client/src/net/protocol.ts</c> — indexes, not multipliers, travel over the socket.
/// </summary>
public static class ClockSpeed
{
/// <summary>×½, ×1, ×2, ×3, ×4.</summary>
public static ReadOnlySpan<double> Multipliers => [0.5d, 1d, 2d, 3d, 4d];
/// <summary>Index of ×1, the speed a school starts at.</summary>
public const int DefaultIndex = 1;
public static int Count => Multipliers.Length;
public static bool IsValid(int index) => index >= 0 && index < Multipliers.Length;
/// <summary>Multiplier for a validated index; out-of-range values fall back to ×1.</summary>
public static double MultiplierAt(int index) => IsValid(index) ? Multipliers[index] : Multipliers[DefaultIndex];
}
@@ -1,12 +0,0 @@
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;
}
@@ -1,19 +0,0 @@
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;
}
@@ -1,14 +0,0 @@
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;
}
}
@@ -1,13 +0,0 @@
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;
}
@@ -1,14 +0,0 @@
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;
}
}
+67
View File
@@ -0,0 +1,67 @@
namespace HSchool.Simulation;
/// <summary>
/// In-game calendar of one school. Time only moves while <see cref="IsRunning"/> is set, and it
/// moves by whole fixed steps — never by wall-clock deltas — so the same tick count always
/// produces the same date.
/// </summary>
public sealed class GameClock
{
/// <summary>Earliest date a school may start at; anything below is a typo, not a design choice.</summary>
public static readonly DateTime MinStartDate = new(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc);
public static readonly DateTime MaxStartDate = new(2999, 12, 31, 23, 59, 59, DateTimeKind.Utc);
private int _speedIndex = ClockSpeed.DefaultIndex;
public GameClock(DateTime startDate)
{
if (!IsValidStartDate(startDate))
{
throw new ArgumentOutOfRangeException(nameof(startDate), startDate, "Start date is outside the supported range.");
}
// The game calendar is not tied to a real time zone; UTC keeps serialization unambiguous.
Time = DateTime.SpecifyKind(startDate, DateTimeKind.Utc);
}
public DateTime Time { get; private set; }
/// <summary>
/// Schools live on their own: a new calendar starts running and only the player's pause
/// button stops it. Leaving for the menu does not.
/// </summary>
public bool IsRunning { get; set; } = true;
/// <summary>Index into <see cref="ClockSpeed.Multipliers"/>; invalid values are ignored.</summary>
public int SpeedIndex
{
get => _speedIndex;
set
{
if (ClockSpeed.IsValid(value))
{
_speedIndex = value;
}
}
}
public double Multiplier => ClockSpeed.MultiplierAt(_speedIndex);
public static bool IsValidStartDate(DateTime date) => date >= MinStartDate && date <= MaxStartDate;
/// <summary>
/// Advances the calendar by one fixed step of <paramref name="realSeconds"/>, scaled by the
/// base rate and the current speed. Does nothing while paused.
/// </summary>
public void Advance(double realSeconds, double gameMinutesPerRealSecond)
{
if (!IsRunning)
{
return;
}
var gameMinutes = realSeconds * gameMinutesPerRealSecond * Multiplier;
Time = Time.AddMinutes(gameMinutes);
}
}
-199
View File
@@ -1,199 +0,0 @@
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));
}
}
@@ -1,16 +1,12 @@
<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>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<RootNamespace>HSchool.Simulation</RootNamespace>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Arch" />
</ItemGroup>
</Project>
@@ -1,12 +0,0 @@
using Arch.Core;
namespace HSchool.Simulation;
/// <summary>
/// One stage of the fixed-step pipeline. Systems run in registration order on the
/// loop thread and must not capture per-step state.
/// </summary>
public interface ISimulationSystem
{
void Update(World world, in SimulationContext context);
}
-22
View File
@@ -1,22 +0,0 @@
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];
}
+54
View File
@@ -0,0 +1,54 @@
using Arch.Core;
namespace HSchool.Simulation;
/// <summary>
/// One save: a name, a calendar and the ECS world that will hold everything the school is made of.
/// The world is empty for now — pupils, rooms and staff land in it as the game grows — but it is
/// created and destroyed with the school so ownership is never in question.
/// </summary>
public sealed class School : IDisposable
{
/// <summary>Longest name a school may carry, in characters.</summary>
public const int MaxNameLength = 40;
private bool _disposed;
internal School(int id, string name, DateTime startDate)
{
Id = id;
Name = name;
Clock = new GameClock(startDate);
World = World.Create();
}
public int Id { get; }
public string Name { get; }
public GameClock Clock { get; }
/// <summary>The Arch world backing this school. Only the loop thread may touch it.</summary>
public World World { get; }
/// <summary>Runs one fixed step of the school. Today that is only the calendar.</summary>
public void Tick(double deltaTime, double gameMinutesPerRealSecond)
{
ObjectDisposedException.ThrowIf(_disposed, this);
Clock.Advance(deltaTime, gameMinutesPerRealSecond);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Fully qualified: the `World` property would otherwise shadow the type.
Arch.Core.World.Destroy(World);
}
}
@@ -0,0 +1,57 @@
namespace HSchool.Simulation;
/// <summary>
/// Suggestions for the "random name" button. Lives here rather than in the client because the
/// server is the one that knows which names are already taken.
/// </summary>
public sealed class SchoolNameGenerator(Random? random = null)
{
private const int AttemptsBeforeNumbering = 24;
private static readonly string[] Kinds = ["Школа", "Гимназия", "Лицей", "Школа-интернат"];
private static readonly string[] Epithets =
[
"Северная", "Приморская", "Заречная", "Нагорная", "Слободская", "Озёрная",
"Кленовая", "Рябиновая", "Солнечная", "Луговая", "Тихая", "Ясная",
];
private readonly Random _random = random ?? Random.Shared;
/// <summary>
/// A name that is not in <paramref name="taken"/>. Falls back to a numbered name so the
/// button always produces something, even when the pool is exhausted.
/// </summary>
public string Next(IEnumerable<string> taken)
{
var used = new HashSet<string>(taken, StringComparer.OrdinalIgnoreCase);
for (var attempt = 0; attempt < AttemptsBeforeNumbering; attempt++)
{
var candidate = Compose();
if (used.Add(candidate))
{
return candidate;
}
}
for (var number = 1; ; number++)
{
var candidate = $"Школа №{number}";
if (!used.Contains(candidate))
{
return candidate;
}
}
}
private string Compose()
{
var kind = Kinds[_random.Next(Kinds.Length)];
// Half the names are numbered, half are named — both read like a real school.
return _random.Next(2) == 0
? $"{kind} №{_random.Next(1, 100)}"
: $"{kind} «{Epithets[_random.Next(Epithets.Length)]}»";
}
}
+141
View File
@@ -0,0 +1,141 @@
namespace HSchool.Simulation;
/// <summary>Why a school could not be created.</summary>
public enum SchoolCreationError
{
None = 0,
LimitReached,
InvalidName,
InvalidStartDate,
}
/// <summary>Outcome of <see cref="SchoolRegistry.Create"/>: either the school or the reason there is none.</summary>
public readonly record struct SchoolCreationResult(School? School, SchoolCreationError Error)
{
public bool Succeeded => Error == SchoolCreationError.None && School is not null;
public static SchoolCreationResult Failed(SchoolCreationError error) => new(null, error);
}
/// <summary>
/// Every school that currently exists, plus the cap from configuration. Not thread-safe by design —
/// only the loop thread touches it, everything else goes through the command queue in the server.
/// </summary>
public sealed class SchoolRegistry : IDisposable
{
private readonly SimulationOptions _options;
private readonly List<School> _schools = [];
private int _nextId = 1;
private bool _disposed;
public SchoolRegistry(SimulationOptions options)
{
_options = options;
NameGenerator = new SchoolNameGenerator();
}
public SchoolNameGenerator NameGenerator { get; }
public int MaxSchools => _options.MaxSchools;
public int Count => _schools.Count;
public bool IsFull => _schools.Count >= _options.MaxSchools;
/// <summary>Schools in creation order — the order the menu lists them in.</summary>
public IReadOnlyList<School> Schools => _schools;
public School? Find(int id) => _schools.Find(school => school.Id == id);
public SchoolCreationResult Create(string name, DateTime startDate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (IsFull)
{
return SchoolCreationResult.Failed(SchoolCreationError.LimitReached);
}
if (!TryNormalizeName(name, out var normalized))
{
return SchoolCreationResult.Failed(SchoolCreationError.InvalidName);
}
if (!GameClock.IsValidStartDate(startDate))
{
return SchoolCreationResult.Failed(SchoolCreationError.InvalidStartDate);
}
var school = new School(_nextId++, normalized, startDate);
_schools.Add(school);
return new SchoolCreationResult(school, SchoolCreationError.None);
}
public bool Delete(int id)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var school = Find(id);
if (school is null)
{
return false;
}
_schools.Remove(school);
school.Dispose();
return true;
}
/// <summary>Advances every running school by one fixed step.</summary>
public void Tick()
{
ObjectDisposedException.ThrowIf(_disposed, this);
foreach (var school in _schools)
{
school.Tick(_options.FixedDeltaTime, _options.GameMinutesPerRealSecond);
}
}
/// <summary>A name the player has not used yet, for the "random" button in the creation form.</summary>
public string SuggestName() => NameGenerator.Next(_schools.Select(school => school.Name));
/// <summary>Trims, strips control characters and enforces the length limit.</summary>
public static bool TryNormalizeName(string? name, out string normalized)
{
normalized = string.Empty;
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
var cleaned = new string(name.Where(character => !char.IsControl(character)).ToArray()).Trim();
if (cleaned.Length == 0 || cleaned.Length > School.MaxNameLength)
{
return false;
}
normalized = cleaned;
return true;
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
foreach (var school in _schools)
{
school.Dispose();
}
_schools.Clear();
}
}
@@ -1,7 +0,0 @@
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);
+33 -23
View File
@@ -1,23 +1,33 @@
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);
}
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";
private DateTime _defaultStartDate = new(2012, 4, 3, 6, 0, 0, DateTimeKind.Utc);
/// <summary>Fixed simulation steps per second.</summary>
public int TickRate { get; set; } = 20;
/// <summary>How many schools may exist at the same time.</summary>
public int MaxSchools { get; set; } = 6;
/// <summary>Base speed of the game clock: real seconds are multiplied by this many game minutes.</summary>
public double GameMinutesPerRealSecond { get; set; } = 5d;
/// <summary>Prefilled start of a new school; the client shows it in the creation form.</summary>
public DateTime DefaultStartDate
{
get => _defaultStartDate;
// Configuration binding yields Kind=Unspecified, which serializes without a "Z" and makes
// the browser read the date in its own time zone. The game calendar is always UTC.
set => _defaultStartDate = DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
/// <summary>Length of one fixed step.</summary>
public double FixedDeltaTime => 1d / TickRate;
public TimeSpan TickInterval => TimeSpan.FromSeconds(1d / TickRate);
}
@@ -1,22 +0,0 @@
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;
});
}
}
@@ -1,37 +0,0 @@
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;
});
}
}
@@ -1,47 +0,0 @@
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;
}
});
}
}