Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>h-school</title>
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage"></div>
|
||||
<div id="hud">
|
||||
<span data-hud="status">connecting…</span>
|
||||
<span data-hud="tick">tick 0</span>
|
||||
<span data-hud="ping">-- ms</span>
|
||||
<span data-hud="entities">0 entities</span>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1313
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "hschool-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc -b",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"pixi.js": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.10.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.2.1",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { ConnectionStatus } from '../net/connection.ts';
|
||||
|
||||
/** Thin wrapper over the status line in `index.html`. */
|
||||
export class Hud {
|
||||
private readonly fields = new Map<string, HTMLElement>();
|
||||
|
||||
constructor(root: ParentNode = document) {
|
||||
for (const element of root.querySelectorAll<HTMLElement>('[data-hud]')) {
|
||||
this.fields.set(element.dataset['hud'] ?? '', element);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(status: ConnectionStatus): void {
|
||||
this.set('status', status);
|
||||
}
|
||||
|
||||
setTick(tick: number): void {
|
||||
this.set('tick', `tick ${tick}`);
|
||||
}
|
||||
|
||||
setPing(rttMs: number): void {
|
||||
this.set('ping', `${Math.round(rttMs)} ms`);
|
||||
}
|
||||
|
||||
setEntityCount(count: number): void {
|
||||
this.set('entities', `${count} entities`);
|
||||
}
|
||||
|
||||
private set(field: string, text: string): void {
|
||||
const element = this.fields.get(field);
|
||||
if (element !== undefined) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { InputButtons } from '../net/protocol.ts';
|
||||
|
||||
/** How often the button mask is pushed to the server, independent of the render rate. */
|
||||
export const INPUT_SEND_HZ = 30;
|
||||
|
||||
const KEY_BINDINGS: Readonly<Record<string, number>> = {
|
||||
KeyW: InputButtons.Up,
|
||||
ArrowUp: InputButtons.Up,
|
||||
KeyS: InputButtons.Down,
|
||||
ArrowDown: InputButtons.Down,
|
||||
KeyA: InputButtons.Left,
|
||||
ArrowLeft: InputButtons.Left,
|
||||
KeyD: InputButtons.Right,
|
||||
ArrowRight: InputButtons.Right,
|
||||
};
|
||||
|
||||
/** Tracks the keyboard and pushes the current mask on a fixed cadence. */
|
||||
export class InputTracker {
|
||||
private buttons = InputButtons.None;
|
||||
private timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
private readonly onKeyDown = (event: KeyboardEvent): void => {
|
||||
const button = KEY_BINDINGS[event.code];
|
||||
if (button !== undefined) {
|
||||
this.buttons |= button;
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onKeyUp = (event: KeyboardEvent): void => {
|
||||
const button = KEY_BINDINGS[event.code];
|
||||
if (button !== undefined) {
|
||||
this.buttons &= ~button;
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
// Alt-tabbing away must not leave a key stuck down.
|
||||
private readonly onBlur = (): void => {
|
||||
this.buttons = InputButtons.None;
|
||||
};
|
||||
|
||||
constructor(private readonly send: (buttons: number) => void) {}
|
||||
|
||||
get current(): number {
|
||||
return this.buttons;
|
||||
}
|
||||
|
||||
start(target: Window = window): void {
|
||||
target.addEventListener('keydown', this.onKeyDown);
|
||||
target.addEventListener('keyup', this.onKeyUp);
|
||||
target.addEventListener('blur', this.onBlur);
|
||||
|
||||
this.timer = setInterval(() => this.send(this.buttons), 1000 / INPUT_SEND_HZ);
|
||||
}
|
||||
|
||||
stop(target: Window = window): void {
|
||||
target.removeEventListener('keydown', this.onKeyDown);
|
||||
target.removeEventListener('keyup', this.onKeyUp);
|
||||
target.removeEventListener('blur', this.onBlur);
|
||||
|
||||
if (this.timer !== null) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Application, Container, Graphics } from 'pixi.js';
|
||||
import { EntityKind, type EntitySnapshot } from '../net/protocol.ts';
|
||||
|
||||
const FIELD_BORDER_COLOR = 0x2a3242;
|
||||
const OWN_PLAYER_RING_COLOR = 0xffffff;
|
||||
|
||||
/**
|
||||
* Draws the interpolated world state. One PixiJS `Graphics` per replicated entity,
|
||||
* created on first sight and destroyed when the entity disappears from a snapshot.
|
||||
*/
|
||||
export class WorldRenderer {
|
||||
private readonly world = new Container();
|
||||
private readonly field = new Graphics();
|
||||
private readonly sprites = new Map<number, Graphics>();
|
||||
|
||||
private worldWidth = 1600;
|
||||
private worldHeight = 900;
|
||||
private ownEntityId = 0;
|
||||
|
||||
constructor(private readonly app: Application) {
|
||||
this.world.addChild(this.field);
|
||||
this.app.stage.addChild(this.world);
|
||||
this.app.renderer.on('resize', () => this.layout());
|
||||
}
|
||||
|
||||
/** Called on every welcome frame: the server owns the field size. */
|
||||
configure(worldWidth: number, worldHeight: number, ownEntityId: number): void {
|
||||
this.worldWidth = worldWidth;
|
||||
this.worldHeight = worldHeight;
|
||||
this.ownEntityId = ownEntityId;
|
||||
|
||||
this.field
|
||||
.clear()
|
||||
.rect(0, 0, worldWidth, worldHeight)
|
||||
.stroke({ color: FIELD_BORDER_COLOR, width: 4 });
|
||||
|
||||
this.layout();
|
||||
}
|
||||
|
||||
draw(entities: readonly EntitySnapshot[]): void {
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const entity of entities) {
|
||||
seen.add(entity.id);
|
||||
|
||||
let sprite = this.sprites.get(entity.id);
|
||||
if (sprite === undefined) {
|
||||
sprite = this.createSprite(entity);
|
||||
this.sprites.set(entity.id, sprite);
|
||||
this.world.addChild(sprite);
|
||||
}
|
||||
|
||||
sprite.x = entity.x;
|
||||
sprite.y = entity.y;
|
||||
}
|
||||
|
||||
for (const [id, sprite] of this.sprites) {
|
||||
if (!seen.has(id)) {
|
||||
sprite.destroy();
|
||||
this.sprites.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createSprite(entity: EntitySnapshot): Graphics {
|
||||
const sprite = new Graphics();
|
||||
|
||||
if (entity.kind === EntityKind.Obstacle) {
|
||||
sprite.roundRect(-entity.radius, -entity.radius, entity.radius * 2, entity.radius * 2, 8);
|
||||
} else {
|
||||
sprite.circle(0, 0, entity.radius);
|
||||
}
|
||||
|
||||
sprite.fill({ color: entity.color });
|
||||
|
||||
if (entity.id === this.ownEntityId) {
|
||||
sprite.circle(0, 0, entity.radius + 6).stroke({ color: OWN_PLAYER_RING_COLOR, width: 2, alpha: 0.9 });
|
||||
}
|
||||
|
||||
return sprite;
|
||||
}
|
||||
|
||||
/** Fits the whole field on screen with letterboxing, so every client sees the same area. */
|
||||
private layout(): void {
|
||||
const { width, height } = this.app.renderer.screen;
|
||||
const scale = Math.min(width / this.worldWidth, height / this.worldHeight) * 0.95;
|
||||
|
||||
this.world.scale.set(scale);
|
||||
this.world.x = (width - this.worldWidth * scale) / 2;
|
||||
this.world.y = (height - this.worldHeight * scale) / 2;
|
||||
}
|
||||
|
||||
/** Drops every sprite, e.g. after a reconnect assigns new replication ids. */
|
||||
reset(): void {
|
||||
for (const sprite of this.sprites.values()) {
|
||||
sprite.destroy();
|
||||
}
|
||||
this.sprites.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SnapshotBuffer } from './snapshotBuffer.ts';
|
||||
import { EntityKind, type SnapshotMessage } from '../net/protocol.ts';
|
||||
|
||||
function snapshot(tick: number, x: number): SnapshotMessage {
|
||||
return {
|
||||
type: 'snapshot',
|
||||
tick,
|
||||
entities: [{ id: 1, kind: EntityKind.Player, x, y: 0, radius: 10, color: 0xffffff }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('SnapshotBuffer', () => {
|
||||
it('returns nothing before the first snapshot', () => {
|
||||
expect(new SnapshotBuffer().sample(1000)).toEqual([]);
|
||||
});
|
||||
|
||||
it('blends the two snapshots straddling the render time', () => {
|
||||
const buffer = new SnapshotBuffer(100);
|
||||
buffer.push(snapshot(1, 0), 1000);
|
||||
buffer.push(snapshot(2, 100), 1100);
|
||||
|
||||
// Render time 1050 sits halfway between the two receive timestamps.
|
||||
const entities = buffer.sample(1150);
|
||||
|
||||
expect(entities[0]?.x).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it('holds at the newest snapshot when the render time has caught up', () => {
|
||||
const buffer = new SnapshotBuffer(0);
|
||||
buffer.push(snapshot(1, 0), 1000);
|
||||
buffer.push(snapshot(2, 100), 1100);
|
||||
|
||||
expect(buffer.sample(5000)[0]?.x).toBe(100);
|
||||
expect(buffer.latestTick).toBe(2);
|
||||
});
|
||||
|
||||
it('drops the history when the tick goes backwards after a reconnect', () => {
|
||||
const buffer = new SnapshotBuffer(100);
|
||||
buffer.push(snapshot(50, 500), 1000);
|
||||
buffer.push(snapshot(1, 0), 2000);
|
||||
|
||||
expect(buffer.size).toBe(1);
|
||||
expect(buffer.latestTick).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { EntitySnapshot, SnapshotMessage } from '../net/protocol.ts';
|
||||
|
||||
/** How far in the past we render, so there is always a newer snapshot to interpolate towards. */
|
||||
export const DEFAULT_INTERPOLATION_DELAY_MS = 100;
|
||||
|
||||
const MAX_BUFFERED_SNAPSHOTS = 32;
|
||||
|
||||
interface BufferedSnapshot {
|
||||
readonly tick: number;
|
||||
readonly receivedAt: number;
|
||||
readonly entities: readonly EntitySnapshot[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the last few snapshots and samples them slightly in the past, blending the two
|
||||
* that straddle the render time. That is what turns 20 discrete server ticks into smooth
|
||||
* motion at display refresh rate.
|
||||
*/
|
||||
export class SnapshotBuffer {
|
||||
private readonly snapshots: BufferedSnapshot[] = [];
|
||||
|
||||
constructor(private readonly delayMs: number = DEFAULT_INTERPOLATION_DELAY_MS) {}
|
||||
|
||||
get latestTick(): number {
|
||||
return this.snapshots.at(-1)?.tick ?? 0;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.snapshots.length;
|
||||
}
|
||||
|
||||
push(message: SnapshotMessage, receivedAt: number): void {
|
||||
// Out-of-order frames cannot happen on a WebSocket, but a reconnect resets the tick.
|
||||
const previous = this.snapshots.at(-1);
|
||||
if (previous !== undefined && message.tick < previous.tick) {
|
||||
this.snapshots.length = 0;
|
||||
}
|
||||
|
||||
this.snapshots.push({ tick: message.tick, receivedAt, entities: message.entities });
|
||||
|
||||
if (this.snapshots.length > MAX_BUFFERED_SNAPSHOTS) {
|
||||
this.snapshots.splice(0, this.snapshots.length - MAX_BUFFERED_SNAPSHOTS);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the interpolated world state for `now` (a `performance.now()` timestamp). */
|
||||
sample(now: number): readonly EntitySnapshot[] {
|
||||
if (this.snapshots.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this.snapshots.length === 1) {
|
||||
return this.snapshots[0]!.entities;
|
||||
}
|
||||
|
||||
const renderTime = now - this.delayMs;
|
||||
|
||||
// Newest pair whose older half is at or before the render time.
|
||||
let older = this.snapshots[0]!;
|
||||
let newer = this.snapshots[1]!;
|
||||
for (let i = this.snapshots.length - 1; i > 0; i--) {
|
||||
if (this.snapshots[i - 1]!.receivedAt <= renderTime) {
|
||||
older = this.snapshots[i - 1]!;
|
||||
newer = this.snapshots[i]!;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const span = newer.receivedAt - older.receivedAt;
|
||||
const t = span <= 0 ? 1 : clamp01((renderTime - older.receivedAt) / span);
|
||||
|
||||
return interpolate(older.entities, newer.entities, t);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.snapshots.length = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function interpolate(
|
||||
older: readonly EntitySnapshot[],
|
||||
newer: readonly EntitySnapshot[],
|
||||
t: number,
|
||||
): readonly EntitySnapshot[] {
|
||||
if (t >= 1) {
|
||||
return newer;
|
||||
}
|
||||
|
||||
const previousById = new Map(older.map((entity) => [entity.id, entity]));
|
||||
|
||||
// Entities missing from `newer` are gone; entities missing from `older` just spawned
|
||||
// and are drawn at their first known position.
|
||||
return newer.map((entity) => {
|
||||
const previous = previousById.get(entity.id);
|
||||
if (previous === undefined) {
|
||||
return entity;
|
||||
}
|
||||
|
||||
return {
|
||||
...entity,
|
||||
x: previous.x + (entity.x - previous.x) * t,
|
||||
y: previous.y + (entity.y - previous.y) * t,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function clamp01(value: number): number {
|
||||
return value < 0 ? 0 : value > 1 ? 1 : value;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Application } from 'pixi.js';
|
||||
import { GameConnection, gameSocketUrl } from './net/connection.ts';
|
||||
import { Hud } from './game/hud.ts';
|
||||
import { InputTracker } from './game/input.ts';
|
||||
import { WorldRenderer } from './game/renderer.ts';
|
||||
import { SnapshotBuffer } from './game/snapshotBuffer.ts';
|
||||
import './style.css';
|
||||
|
||||
const BACKGROUND_COLOR = 0x10141c;
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app = new Application();
|
||||
await app.init({
|
||||
background: BACKGROUND_COLOR,
|
||||
resizeTo: window,
|
||||
antialias: true,
|
||||
autoDensity: true,
|
||||
resolution: window.devicePixelRatio,
|
||||
});
|
||||
|
||||
document.getElementById('stage')?.appendChild(app.canvas);
|
||||
|
||||
const hud = new Hud();
|
||||
const renderer = new WorldRenderer(app);
|
||||
const snapshots = new SnapshotBuffer();
|
||||
|
||||
const connection = new GameConnection(gameSocketUrl(), playerName(), {
|
||||
onStatus: (status) => {
|
||||
hud.setStatus(status);
|
||||
if (status !== 'connected') {
|
||||
snapshots.clear();
|
||||
}
|
||||
},
|
||||
onWelcome: (welcome) => {
|
||||
// Replication ids are per-session, so anything drawn before this point is stale.
|
||||
renderer.reset();
|
||||
renderer.configure(welcome.worldWidth, welcome.worldHeight, welcome.playerEntityId);
|
||||
},
|
||||
onSnapshot: (snapshot, receivedAt) => {
|
||||
snapshots.push(snapshot, receivedAt);
|
||||
hud.setTick(snapshot.tick);
|
||||
hud.setEntityCount(snapshot.entities.length);
|
||||
},
|
||||
onLatency: (rttMs) => hud.setPing(rttMs),
|
||||
});
|
||||
|
||||
const input = new InputTracker((buttons) => connection.sendInput(buttons));
|
||||
|
||||
app.ticker.add(() => renderer.draw(snapshots.sample(performance.now())));
|
||||
|
||||
connection.connect();
|
||||
input.start();
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
input.stop();
|
||||
connection.close();
|
||||
});
|
||||
}
|
||||
|
||||
/** Keeps a name across reloads; replace with a real login when one exists. */
|
||||
function playerName(): string {
|
||||
const stored = localStorage.getItem('hschool.playerName');
|
||||
if (stored !== null) {
|
||||
return stored;
|
||||
}
|
||||
|
||||
const generated = `player-${Math.floor(Math.random() * 10000)}`;
|
||||
localStorage.setItem('hschool.playerName', generated);
|
||||
return generated;
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
decodeServerMessage,
|
||||
encodeHello,
|
||||
encodeInput,
|
||||
encodePing,
|
||||
ProtocolError,
|
||||
type ServerMessage,
|
||||
type SnapshotMessage,
|
||||
type WelcomeMessage,
|
||||
} from './protocol.ts';
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'closed';
|
||||
|
||||
export interface ConnectionHandlers {
|
||||
onStatus?(status: ConnectionStatus): void;
|
||||
onWelcome?(message: WelcomeMessage): void;
|
||||
onSnapshot?(message: SnapshotMessage, receivedAt: number): void;
|
||||
/** Round-trip time in milliseconds. */
|
||||
onLatency?(rttMs: number): void;
|
||||
}
|
||||
|
||||
const PING_INTERVAL_MS = 2000;
|
||||
const RECONNECT_MIN_MS = 500;
|
||||
const RECONNECT_MAX_MS = 8000;
|
||||
|
||||
/**
|
||||
* Owns the WebSocket: handshake, reconnect with backoff, ping/pong and outbound input.
|
||||
* Rendering code only sees decoded messages.
|
||||
*/
|
||||
export class GameConnection {
|
||||
private socket: WebSocket | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectDelay = RECONNECT_MIN_MS;
|
||||
private inputSequence = 0;
|
||||
private closedByUs = false;
|
||||
|
||||
constructor(
|
||||
private readonly url: string,
|
||||
private readonly playerName: string,
|
||||
private readonly handlers: ConnectionHandlers = {},
|
||||
) {}
|
||||
|
||||
connect(): void {
|
||||
this.closedByUs = false;
|
||||
this.handlers.onStatus?.(this.reconnectDelay === RECONNECT_MIN_MS ? 'connecting' : 'reconnecting');
|
||||
|
||||
const socket = new WebSocket(this.url);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
this.socket = socket;
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
this.reconnectDelay = RECONNECT_MIN_MS;
|
||||
socket.send(encodeHello(this.playerName));
|
||||
this.handlers.onStatus?.('connected');
|
||||
this.startPinging();
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => this.handleMessage(event));
|
||||
socket.addEventListener('close', () => this.handleClose());
|
||||
socket.addEventListener('error', () => socket.close());
|
||||
}
|
||||
|
||||
/** Sends the current button mask; called at a fixed rate by the input loop. */
|
||||
sendInput(buttons: number): void {
|
||||
if (this.socket?.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inputSequence = (this.inputSequence + 1) >>> 0;
|
||||
this.socket.send(encodeInput(this.inputSequence, buttons));
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closedByUs = true;
|
||||
this.stopTimers();
|
||||
this.socket?.close();
|
||||
this.socket = null;
|
||||
this.handlers.onStatus?.('closed');
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
if (!(event.data instanceof ArrayBuffer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message: ServerMessage | null;
|
||||
try {
|
||||
message = decodeServerMessage(event.data);
|
||||
} catch (error) {
|
||||
if (error instanceof ProtocolError) {
|
||||
console.warn('Dropping malformed frame:', error.message);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (message === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case 'welcome':
|
||||
this.handlers.onWelcome?.(message);
|
||||
break;
|
||||
case 'snapshot':
|
||||
this.handlers.onSnapshot?.(message, performance.now());
|
||||
break;
|
||||
case 'pong':
|
||||
this.handlers.onLatency?.(Math.max(0, Date.now() - message.clientTimeMs));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private handleClose(): void {
|
||||
this.stopTimers();
|
||||
this.socket = null;
|
||||
|
||||
if (this.closedByUs) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.handlers.onStatus?.('reconnecting');
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), this.reconnectDelay);
|
||||
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
}
|
||||
|
||||
private startPinging(): void {
|
||||
this.stopPinging();
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.socket?.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(encodePing(Date.now()));
|
||||
}
|
||||
}, PING_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopPinging(): void {
|
||||
if (this.pingTimer !== null) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private stopTimers(): void {
|
||||
this.stopPinging();
|
||||
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the game socket URL from the page origin, so the Vite proxy handles it in dev. */
|
||||
export function gameSocketUrl(path = '/ws/game'): string {
|
||||
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${scheme}//${location.host}${path}`;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodeServerMessage,
|
||||
encodeHello,
|
||||
encodeInput,
|
||||
encodePing,
|
||||
EntityKind,
|
||||
InputButtons,
|
||||
MessageType,
|
||||
ProtocolError,
|
||||
PROTOCOL_VERSION,
|
||||
} from './protocol.ts';
|
||||
|
||||
/**
|
||||
* These byte layouts are the contract with `ProtocolCodec.cs`. If a test here has to
|
||||
* change, the C# codec and `docs/protocol.md` change with it.
|
||||
*/
|
||||
describe('client encoders', () => {
|
||||
it('writes a hello frame with version and UTF-8 name', () => {
|
||||
const view = new DataView(encodeHello('ada'));
|
||||
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientHello);
|
||||
expect(view.getUint8(1)).toBe(PROTOCOL_VERSION);
|
||||
expect(view.getUint8(2)).toBe(3);
|
||||
expect(view.byteLength).toBe(6);
|
||||
});
|
||||
|
||||
it('clamps oversized names to 32 bytes', () => {
|
||||
const view = new DataView(encodeHello('x'.repeat(100)));
|
||||
|
||||
expect(view.getUint8(2)).toBe(32);
|
||||
expect(view.byteLength).toBe(35);
|
||||
});
|
||||
|
||||
it('writes an input frame little-endian', () => {
|
||||
const buttons = InputButtons.Up | InputButtons.Right;
|
||||
const view = new DataView(encodeInput(0x01020304, buttons));
|
||||
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientInput);
|
||||
expect(view.getUint32(1, true)).toBe(0x01020304);
|
||||
expect(view.getUint8(5)).toBe(buttons);
|
||||
});
|
||||
|
||||
it('writes a ping frame carrying the client clock', () => {
|
||||
const view = new DataView(encodePing(1_700_000_000_123));
|
||||
|
||||
expect(view.getUint8(0)).toBe(MessageType.ClientPing);
|
||||
expect(Number(view.getBigInt64(1, true))).toBe(1_700_000_000_123);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeServerMessage', () => {
|
||||
it('reads a welcome frame', () => {
|
||||
const buffer = new ArrayBuffer(15);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerWelcome);
|
||||
view.setUint8(1, PROTOCOL_VERSION);
|
||||
view.setUint32(2, 42, true);
|
||||
view.setUint8(6, 20);
|
||||
view.setFloat32(7, 1600, true);
|
||||
view.setFloat32(11, 900, true);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({
|
||||
type: 'welcome',
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
playerEntityId: 42,
|
||||
tickRate: 20,
|
||||
worldWidth: 1600,
|
||||
worldHeight: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a snapshot with every entity field', () => {
|
||||
const buffer = new ArrayBuffer(7 + 21);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerSnapshot);
|
||||
view.setUint32(1, 1234, true);
|
||||
view.setUint16(5, 1, true);
|
||||
view.setUint32(7, 7, true);
|
||||
view.setUint8(11, EntityKind.Player);
|
||||
view.setFloat32(12, 100, true);
|
||||
view.setFloat32(16, 200, true);
|
||||
view.setFloat32(20, 18, true);
|
||||
view.setUint32(24, 0x4cc9f0, true);
|
||||
|
||||
const message = decodeServerMessage(buffer);
|
||||
|
||||
expect(message).toEqual({
|
||||
type: 'snapshot',
|
||||
tick: 1234,
|
||||
entities: [{ id: 7, kind: EntityKind.Player, x: 100, y: 200, radius: 18, color: 0x4cc9f0 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('reads a pong frame', () => {
|
||||
const buffer = new ArrayBuffer(13);
|
||||
const view = new DataView(buffer);
|
||||
view.setUint8(0, MessageType.ServerPong);
|
||||
view.setBigInt64(1, 5n, true);
|
||||
view.setUint32(9, 99, true);
|
||||
|
||||
expect(decodeServerMessage(buffer)).toEqual({ type: 'pong', clientTimeMs: 5, serverTick: 99 });
|
||||
});
|
||||
|
||||
it('ignores unknown message ids so new ones stay backwards compatible', () => {
|
||||
const buffer = new Uint8Array([0xf0, 0x00]).buffer;
|
||||
|
||||
expect(decodeServerMessage(buffer)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a truncated frame', () => {
|
||||
const buffer = new Uint8Array([MessageType.ServerWelcome, PROTOCOL_VERSION]).buffer;
|
||||
|
||||
expect(() => decodeServerMessage(buffer)).toThrow(ProtocolError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Browser side of the binary wire format.
|
||||
*
|
||||
* This file is the mirror of `src/HSchool.Protocol/ProtocolCodec.cs`; the two must be
|
||||
* changed together and documented in `docs/protocol.md`. All numbers are little-endian.
|
||||
*/
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
|
||||
export const MessageType = {
|
||||
ClientHello: 0x01,
|
||||
ClientInput: 0x02,
|
||||
ClientPing: 0x03,
|
||||
ServerWelcome: 0x81,
|
||||
ServerSnapshot: 0x82,
|
||||
ServerPong: 0x83,
|
||||
} as const;
|
||||
|
||||
export const InputButtons = {
|
||||
None: 0,
|
||||
Up: 1 << 0,
|
||||
Down: 1 << 1,
|
||||
Left: 1 << 2,
|
||||
Right: 1 << 3,
|
||||
} as const;
|
||||
|
||||
export const EntityKind = {
|
||||
Unknown: 0,
|
||||
Player: 1,
|
||||
Obstacle: 2,
|
||||
} as const;
|
||||
|
||||
export type EntityKindValue = (typeof EntityKind)[keyof typeof EntityKind];
|
||||
|
||||
export interface EntitySnapshot {
|
||||
readonly id: number;
|
||||
readonly kind: EntityKindValue;
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
readonly radius: number;
|
||||
/** Packed 0x00RRGGBB, ready for PixiJS. */
|
||||
readonly color: number;
|
||||
}
|
||||
|
||||
export interface WelcomeMessage {
|
||||
readonly type: 'welcome';
|
||||
readonly protocolVersion: number;
|
||||
/** Replication id of this client's own avatar. */
|
||||
readonly playerEntityId: number;
|
||||
readonly tickRate: number;
|
||||
readonly worldWidth: number;
|
||||
readonly worldHeight: number;
|
||||
}
|
||||
|
||||
export interface SnapshotMessage {
|
||||
readonly type: 'snapshot';
|
||||
readonly tick: number;
|
||||
readonly entities: readonly EntitySnapshot[];
|
||||
}
|
||||
|
||||
export interface PongMessage {
|
||||
readonly type: 'pong';
|
||||
readonly clientTimeMs: number;
|
||||
readonly serverTick: number;
|
||||
}
|
||||
|
||||
export type ServerMessage = WelcomeMessage | SnapshotMessage | PongMessage;
|
||||
|
||||
/** Thrown when a frame is truncated or carries an unexpected message id. */
|
||||
export class ProtocolError extends Error {}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
export function encodeHello(playerName: string): ArrayBuffer {
|
||||
const name = encoder.encode(playerName).slice(0, 32);
|
||||
const buffer = new ArrayBuffer(3 + name.length);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, MessageType.ClientHello);
|
||||
view.setUint8(1, PROTOCOL_VERSION);
|
||||
view.setUint8(2, name.length);
|
||||
new Uint8Array(buffer, 3).set(name);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function encodeInput(sequence: number, buttons: number): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(6);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, MessageType.ClientInput);
|
||||
view.setUint32(1, sequence >>> 0, true);
|
||||
view.setUint8(5, buttons & 0xff);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function encodePing(clientTimeMs: number): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(9);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
view.setUint8(0, MessageType.ClientPing);
|
||||
view.setBigInt64(1, BigInt(Math.trunc(clientTimeMs)), true);
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** Decodes one server frame. Unknown message ids return `null` so new ids stay backwards compatible. */
|
||||
export function decodeServerMessage(data: ArrayBuffer): ServerMessage | null {
|
||||
if (data.byteLength === 0) {
|
||||
throw new ProtocolError('Empty frame.');
|
||||
}
|
||||
|
||||
const view = new DataView(data);
|
||||
const messageType = view.getUint8(0);
|
||||
|
||||
switch (messageType) {
|
||||
case MessageType.ServerWelcome:
|
||||
return decodeWelcome(view);
|
||||
case MessageType.ServerSnapshot:
|
||||
return decodeSnapshot(view);
|
||||
case MessageType.ServerPong:
|
||||
return decodePong(view);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeWelcome(view: DataView): WelcomeMessage {
|
||||
ensure(view, 15);
|
||||
|
||||
return {
|
||||
type: 'welcome',
|
||||
protocolVersion: view.getUint8(1),
|
||||
playerEntityId: view.getUint32(2, true),
|
||||
tickRate: view.getUint8(6),
|
||||
worldWidth: view.getFloat32(7, true),
|
||||
worldHeight: view.getFloat32(11, true),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSnapshot(view: DataView): SnapshotMessage {
|
||||
ensure(view, 7);
|
||||
|
||||
const tick = view.getUint32(1, true);
|
||||
const count = view.getUint16(5, true);
|
||||
const entitySize = 21;
|
||||
ensure(view, 7 + count * entitySize);
|
||||
|
||||
const entities: EntitySnapshot[] = new Array(count);
|
||||
let offset = 7;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
entities[i] = {
|
||||
id: view.getUint32(offset, true),
|
||||
kind: view.getUint8(offset + 4) as EntityKindValue,
|
||||
x: view.getFloat32(offset + 5, true),
|
||||
y: view.getFloat32(offset + 9, true),
|
||||
radius: view.getFloat32(offset + 13, true),
|
||||
color: view.getUint32(offset + 17, true),
|
||||
};
|
||||
offset += entitySize;
|
||||
}
|
||||
|
||||
return { type: 'snapshot', tick, entities };
|
||||
}
|
||||
|
||||
function decodePong(view: DataView): PongMessage {
|
||||
ensure(view, 13);
|
||||
|
||||
return {
|
||||
type: 'pong',
|
||||
clientTimeMs: Number(view.getBigInt64(1, true)),
|
||||
serverTick: view.getUint32(9, true),
|
||||
};
|
||||
}
|
||||
|
||||
function ensure(view: DataView, bytes: number): void {
|
||||
if (view.byteLength < bytes) {
|
||||
throw new ProtocolError(`Truncated frame: expected ${bytes} bytes, got ${view.byteLength}.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: ui-monospace, "Cascadia Mono", "Segoe UI Mono", monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
background: #10141c;
|
||||
color: #d7e0ef;
|
||||
}
|
||||
|
||||
#stage canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#hud {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid #2a3242;
|
||||
border-radius: 8px;
|
||||
background: rgba(16, 20, 28, 0.72);
|
||||
font-size: 13px;
|
||||
letter-spacing: 0.02em;
|
||||
pointer-events: none;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Aspire injects SERVER_HTTP / SERVER_HTTPS from the `server` resource reference,
|
||||
// so the dev server proxies to whatever port the backend actually got.
|
||||
const backend = process.env.SERVER_HTTPS ?? process.env.SERVER_HTTP ?? 'http://localhost:5180';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: backend,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/ws': {
|
||||
target: backend,
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: 'es2022',
|
||||
sourcemap: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user