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.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 'школ';
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user