Update .gitignore to exclude TypeScript build info and add dist directory. Expand README with project overview, technology stack, prerequisites, and instructions for running and testing the application.
ci / server (push) Failing after 4m10s
ci / client (push) Successful in 17s

This commit is contained in:
Leonid Pershin
2026-08-18 11:11:48 +03:00
parent 84aafb0b69
commit e6739e7912
84 changed files with 5698 additions and 2 deletions
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+19
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+35
View File
@@ -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;
}
}
}
+67
View File
@@ -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;
}
}
}
+100
View File
@@ -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;
}
+72
View File
@@ -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();
+158
View File
@@ -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}`;
}
+116
View File
@@ -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);
});
});
+182
View File
@@ -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}.`);
}
}
+34
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+24
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+24
View File
@@ -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"]
}
+27
View File
@@ -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,
},
});