|
|
@@ -4,7 +4,7 @@ import { Anim, CHARACTER_IDS } from '../shared/characters';
|
|
|
import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
|
|
|
import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
|
|
|
import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
|
|
|
-import { hello, type ChunkState, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
|
|
|
+import { hello, type ChunkInfo, type ChunkState, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
|
|
|
import type { TerrainBitmap } from '../shared/terrain/bitmap';
|
|
|
import { applyTerrainEvent, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
|
|
|
import { rleDecode } from '../shared/terrain/rle';
|
|
|
@@ -35,7 +35,9 @@ import { InputTracker } from './input';
|
|
|
import { Connection } from './net/connection';
|
|
|
import { SnapshotBuffer } from './net/interpolation';
|
|
|
import { TerrainView } from './terrain/TerrainView';
|
|
|
-import { showMenu } from './ui/menu';
|
|
|
+import { showBrowser } from './ui/browser';
|
|
|
+import { LobbyPanel } from './ui/lobbyPanel';
|
|
|
+import { showMenu, type MenuChoice } from './ui/menu';
|
|
|
|
|
|
const PORTRAIT_RADIUS = 34;
|
|
|
const EATING_POPUP_RANGE = 120;
|
|
|
@@ -47,24 +49,22 @@ const setStatus = (text: string): void => {
|
|
|
statusEl.textContent = text;
|
|
|
};
|
|
|
|
|
|
+/** Everything tied to one loaded map. Rebuilt when a match starts. */
|
|
|
interface World {
|
|
|
materials: MaterialRegistry;
|
|
|
terrain: TerrainBitmap;
|
|
|
view: TerrainView;
|
|
|
+ background: Sprite;
|
|
|
lastSeq: number;
|
|
|
}
|
|
|
|
|
|
-async function loadWorld(mapId: string, materials: MaterialRegistry, root: Container): Promise<World> {
|
|
|
+async function loadWorld(mapId: string, materials: MaterialRegistry): Promise<World> {
|
|
|
const files = await window.assets.map(mapId);
|
|
|
const def = parseMapDefinition(files.definition, mapId);
|
|
|
const terrain = buildTerrain(def, files.terrain, materials);
|
|
|
-
|
|
|
const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
|
|
|
- root.addChild(new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) })));
|
|
|
-
|
|
|
- const view = new TerrainView(terrain, materials);
|
|
|
- root.addChild(view.container);
|
|
|
- return { materials, terrain, view, lastSeq: 0 };
|
|
|
+ const background = new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) }));
|
|
|
+ return { materials, terrain, view: new TerrainView(terrain, materials), background, lastSeq: 0 };
|
|
|
}
|
|
|
|
|
|
/** Screen-space warm vignette drawn over the game. */
|
|
|
@@ -94,14 +94,29 @@ function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
|
|
|
return out;
|
|
|
}
|
|
|
|
|
|
+/** Title menu then game browser, until the player picks a game to host or join. */
|
|
|
+async function chooseGame(app: Application): Promise<{ choice: MenuChoice; address: string; port: number }> {
|
|
|
+ const portraits = makePortraits(app);
|
|
|
+ for (;;) {
|
|
|
+ const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits);
|
|
|
+ const target = await showBrowser();
|
|
|
+ if (target.mode === 'back') continue;
|
|
|
+ if (target.mode === 'join') return { choice, address: target.address, port: target.port };
|
|
|
+ setStatus(fr.connecting);
|
|
|
+ const { port } = await window.lan.host(fr.browser.gameName(choice.name));
|
|
|
+ return { choice, address: '127.0.0.1', port };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
async function start(): Promise<void> {
|
|
|
const app = new Application();
|
|
|
const host = document.getElementById('game')!;
|
|
|
await app.init({ resizeTo: window, background: 0xfff1d6, antialias: true });
|
|
|
host.appendChild(app.canvas);
|
|
|
|
|
|
- const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, makePortraits(app));
|
|
|
+ const { choice, address, port } = await chooseGame(app);
|
|
|
helpEl.textContent = fr.help[choice.characterId] ?? '';
|
|
|
+ setStatus(fr.connecting);
|
|
|
|
|
|
const camera = new Container();
|
|
|
const worldRoot = new Container();
|
|
|
@@ -112,18 +127,22 @@ async function start(): Promise<void> {
|
|
|
const popups = new Popups();
|
|
|
const playersLayer = new Container();
|
|
|
const chunksLayer = new Container();
|
|
|
+ // Map-independent layers always sit above the map ones (background, terrain).
|
|
|
+ worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
|
|
|
|
|
|
- setStatus(fr.connecting);
|
|
|
- const { port } = await window.lan.host();
|
|
|
const materials = parseMaterials(await window.assets.materials());
|
|
|
|
|
|
let world: World | null = null;
|
|
|
+ /** Bumped on every map (re)load, so a slow load never installs a stale world. */
|
|
|
+ let generation = 0;
|
|
|
let localId = -1;
|
|
|
let pendingEvents: SequencedEvent[] = [];
|
|
|
- const infos = new Map<number, PlayerInfo>();
|
|
|
+ /** Server hash waiting for this client to reach the same event seq. */
|
|
|
+ let pendingHash: { seq: number; hash: number } | null = null;
|
|
|
+ let infos = new Map<number, PlayerInfo>();
|
|
|
const views = new Map<number, PlayerView>();
|
|
|
const chunkViews = new Map<number, ChunkView>();
|
|
|
- const snapshots = new SnapshotBuffer();
|
|
|
+ let snapshots = new SnapshotBuffer();
|
|
|
const input = new InputTracker(app.canvas);
|
|
|
let states = new Map<number, PlayerState>();
|
|
|
let shake = 0;
|
|
|
@@ -160,6 +179,18 @@ async function start(): Promise<void> {
|
|
|
return m ? parseHexColor(m.color) : 0xffffff;
|
|
|
};
|
|
|
|
|
|
+ /** Compares our bitmap with the server's once we have applied the same events (readme §9, M3). */
|
|
|
+ const checkHash = (): void => {
|
|
|
+ if (!world || !pendingHash || world.lastSeq !== pendingHash.seq) return;
|
|
|
+ const mine = world.terrain.hash();
|
|
|
+ if (mine !== pendingHash.hash) {
|
|
|
+ console.error(`[net] DESYNC at seq ${pendingHash.seq}: server ${pendingHash.hash}, client ${mine}`);
|
|
|
+ setStatus(fr.desync);
|
|
|
+ conn.send({ t: 'desync', seq: pendingHash.seq, hash: mine });
|
|
|
+ }
|
|
|
+ pendingHash = null;
|
|
|
+ };
|
|
|
+
|
|
|
/**
|
|
|
* Applies one terrain event in sequence. `live` is false while replaying history:
|
|
|
* no effects, and no chunk sprites (chunks still falling come from the welcome message).
|
|
|
@@ -173,6 +204,7 @@ async function start(): Promise<void> {
|
|
|
world.lastSeq = seq;
|
|
|
const { removedByMaterial } = applyTerrainEvent(world.terrain, ev, world.materials);
|
|
|
world.view.onEvent(ev);
|
|
|
+ checkHash();
|
|
|
|
|
|
if (ev.kind === 'op') {
|
|
|
if (!live) return;
|
|
|
@@ -206,24 +238,59 @@ async function start(): Promise<void> {
|
|
|
popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
|
|
|
};
|
|
|
|
|
|
+ /** Drops the current map and everything on it, then loads `mapId` and replays `history`. */
|
|
|
+ const resetWorld = (mapId: string, history: SequencedEvent[], chunks: ChunkInfo[]): void => {
|
|
|
+ const gen = ++generation;
|
|
|
+ if (world) {
|
|
|
+ world.background.destroy({ texture: true, textureSource: true });
|
|
|
+ world.view.container.destroy({ children: true, texture: true, textureSource: true });
|
|
|
+ }
|
|
|
+ world = null;
|
|
|
+ pendingEvents = [];
|
|
|
+ pendingHash = null;
|
|
|
+ for (const id of [...chunkViews.keys()]) removeChunk(id);
|
|
|
+ for (const view of views.values()) view.destroy();
|
|
|
+ views.clear();
|
|
|
+ snapshots = new SnapshotBuffer();
|
|
|
+ setStatus(fr.loadingMap);
|
|
|
+ void loadWorld(mapId, materials)
|
|
|
+ .then((w) => {
|
|
|
+ if (gen !== generation) return; // superseded by a newer start
|
|
|
+ world = w;
|
|
|
+ worldRoot.addChildAt(w.background, 0);
|
|
|
+ worldRoot.addChildAt(w.view.container, 1);
|
|
|
+ for (const { seq, ev } of history) applyServerEvent(seq, ev, false);
|
|
|
+ for (const c of chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
|
|
|
+ const live = pendingEvents;
|
|
|
+ pendingEvents = [];
|
|
|
+ for (const { seq, ev } of live) applyServerEvent(seq, ev, true);
|
|
|
+ setStatus('');
|
|
|
+ })
|
|
|
+ .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
|
|
|
+ };
|
|
|
+
|
|
|
+ const lobbyPanel = new LobbyPanel(
|
|
|
+ () => localId,
|
|
|
+ (ready) => conn.send({ t: 'ready', ready }),
|
|
|
+ );
|
|
|
+
|
|
|
const onMessage = (msg: ServerMessage): void => {
|
|
|
switch (msg.t) {
|
|
|
case 'welcome':
|
|
|
localId = msg.playerId;
|
|
|
- msg.players.forEach((p) => infos.set(p.id, p));
|
|
|
- setStatus(fr.loadingMap);
|
|
|
- void loadWorld(msg.mapId, materials, worldRoot)
|
|
|
- .then((w) => {
|
|
|
- world = w;
|
|
|
- worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
|
|
|
- for (const { seq, ev } of msg.events) applyServerEvent(seq, ev, false);
|
|
|
- for (const c of msg.chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
|
|
|
- const live = pendingEvents;
|
|
|
- pendingEvents = [];
|
|
|
- for (const { seq, ev } of live) applyServerEvent(seq, ev, true);
|
|
|
- setStatus('');
|
|
|
- })
|
|
|
- .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
|
|
|
+ infos = new Map(msg.players.map((p) => [p.id, p]));
|
|
|
+ resetWorld(msg.mapId, msg.events, msg.chunks);
|
|
|
+ break;
|
|
|
+ case 'start':
|
|
|
+ infos = new Map(msg.players.map((p) => [p.id, p]));
|
|
|
+ resetWorld(msg.mapId, [], []);
|
|
|
+ setStatus(fr.lobby.started);
|
|
|
+ setTimeout(() => {
|
|
|
+ if (statusEl.textContent === fr.lobby.started) setStatus('');
|
|
|
+ }, 1500);
|
|
|
+ break;
|
|
|
+ case 'lobby':
|
|
|
+ lobbyPanel.update(msg.phase, msg.players, msg.countdown);
|
|
|
break;
|
|
|
case 'reject':
|
|
|
setStatus(
|
|
|
@@ -245,6 +312,10 @@ async function start(): Promise<void> {
|
|
|
case 'terrain':
|
|
|
applyServerEvent(msg.seq, msg.ev, true);
|
|
|
break;
|
|
|
+ case 'hash':
|
|
|
+ pendingHash = { seq: msg.seq, hash: msg.hash };
|
|
|
+ checkHash();
|
|
|
+ break;
|
|
|
case 'chunkRemoved':
|
|
|
removeChunk(msg.id);
|
|
|
break;
|
|
|
@@ -261,10 +332,13 @@ async function start(): Promise<void> {
|
|
|
};
|
|
|
|
|
|
const conn = new Connection(
|
|
|
- `ws://127.0.0.1:${port}`,
|
|
|
+ `ws://${address}:${port}`,
|
|
|
onMessage,
|
|
|
() => conn.send(hello(choice.name, choice.characterId)),
|
|
|
- () => setStatus(fr.disconnected),
|
|
|
+ () => {
|
|
|
+ setStatus(fr.disconnected);
|
|
|
+ lobbyPanel.update('playing', [], null); // hide it
|
|
|
+ },
|
|
|
);
|
|
|
|
|
|
const screenToWorld = (sx: number, sy: number): [number, number] => [
|