import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy import { Application, CanvasSource, Container, ImageSource, Sprite, Texture } from 'pixi.js'; 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 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'; import { CAMERA_AIM_LEAD, CAMERA_FOLLOW_SPEED, CAMERA_VIEW_HEIGHT, CRUSH_SHAKE, FREEZE_FRAME_MS, HIT_SHAKE, INPUT_SEND_HZ, LANDING_DUST_MIN_VY, MERGE_SHAKE, PARTICLES_PER_100_PX, PLAYER_SPLAT_COLOR, POPUP_COOLDOWN, POPUP_MIN_PIXELS, SHAKE_DECAY, SHAKE_MAX, SHAKE_PER_PIXEL, } from './config'; import { ChunkView } from './entities/ChunkView'; import { PlayerView } from './entities/PlayerView'; import { Particles } from './fx/Particles'; import { Popups } from './fx/Popups'; import { fr } from './i18n/fr'; import { InputTracker } from './input'; import { Connection } from './net/connection'; import { SnapshotBuffer } from './net/interpolation'; import { TerrainView } from './terrain/TerrainView'; 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; const statusEl = document.getElementById('status')!; const fpsEl = document.getElementById('fps')!; const helpEl = document.getElementById('help')!; 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): Promise { 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' })); 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. */ function makeVignette(): Sprite { const canvas = document.createElement('canvas'); canvas.width = 512; canvas.height = 512; const ctx = canvas.getContext('2d')!; const grad = ctx.createRadialGradient(256, 230, 140, 256, 256, 380); grad.addColorStop(0, 'rgba(255, 220, 160, 0)'); grad.addColorStop(0.7, 'rgba(120, 60, 20, 0.12)'); grad.addColorStop(1, 'rgba(59, 36, 20, 0.45)'); ctx.fillStyle = grad; ctx.fillRect(0, 0, 512, 512); return new Sprite(new Texture({ source: new CanvasSource({ resource: canvas }) })); } /** Renders each character's idle pose to a canvas for the menu. */ function makePortraits(app: Application): Map { const out = new Map(); CHARACTER_IDS.forEach((characterId, i) => { const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS }); view.drawIdle(); out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 2 }) as HTMLCanvasElement); view.destroy(); }); 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 { const app = new Application(); const host = document.getElementById('game')!; await app.init({ resizeTo: window, background: 0xfff1d6, antialias: true }); host.appendChild(app.canvas); const { choice, address, port } = await chooseGame(app); helpEl.textContent = fr.help[choice.characterId] ?? ''; setStatus(fr.connecting); const camera = new Container(); const worldRoot = new Container(); camera.addChild(worldRoot); const vignette = makeVignette(); app.stage.addChild(camera, vignette); const particles = new Particles(); 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); 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[] = []; /** Server hash waiting for this client to reach the same event seq. */ let pendingHash: { seq: number; hash: number } | null = null; let infos = new Map(); const views = new Map(); const chunkViews = new Map(); let snapshots = new SnapshotBuffer(); const input = new InputTracker(app.canvas); let states = new Map(); let shake = 0; let freezeUntil = 0; let popupCooldown = 0; const nearEater = (x: number, y: number): boolean => { for (const s of states.values()) { if (s.anim & Anim.Special && Math.hypot(s.x - x, s.y - y) < EATING_POPUP_RANGE) return true; } return false; }; const addChunk = (id: number, mask: Uint8Array, w: number, h: number, state: ChunkState): void => { if (!world || chunkViews.has(id)) return; const view = new ChunkView(world.view.makeChunkTexture(mask, w, h, Math.round(state.x), Math.round(state.y)), state); chunkViews.set(id, view); chunksLayer.addChild(view.sprite); }; const removeChunk = (id: number): void => { chunkViews.get(id)?.destroy(); chunkViews.delete(id); }; /** Most common material of a mask, for debris colors. */ const mainColor = (cells: Uint8Array): number => { const counts = new Map(); for (const v of cells) if (v !== 0) counts.set(v, (counts.get(v) ?? 0) + 1); let best = 1; let bestN = -1; for (const [v, n] of counts) if (n > bestN) [best, bestN] = [v, n]; const m = world?.materials.byId[best]; 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). */ const applyServerEvent = (seq: number, ev: TerrainEvent, live: boolean): void => { if (!world) { pendingEvents.push({ seq, ev }); return; } if (seq !== world.lastSeq + 1) console.error(`[net] terrain event out of order: got ${seq}, expected ${world.lastSeq + 1}`); world.lastSeq = seq; const { removedByMaterial } = applyTerrainEvent(world.terrain, ev, world.materials); world.view.onEvent(ev); checkHash(); if (ev.kind === 'op') { if (!live) return; const { op } = ev; let total = 0; for (const [mat, count] of removedByMaterial) { total += count; const m = world.materials.byId[mat]; if (m) particles.burst(op.x, op.y, Math.ceil((count / 100) * PARTICLES_PER_100_PX), parseHexColor(m.color)); } shake = Math.min(SHAKE_MAX, shake + total * SHAKE_PER_PIXEL); if (total >= POPUP_MIN_PIXELS && popupCooldown <= 0) { popupCooldown = POPUP_COOLDOWN; popups.spawn(op.x, op.y - op.r, nearEater(op.x, op.y) ? fr.onomatopoeia.munch : fr.onomatopoeia.crunch); } return; } const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, ev.w * ev.h); if (ev.kind === 'detach') { if (!live) return; if (ev.debris) particles.burst(ev.x + ev.w / 2, ev.y + ev.h / 2, Math.max(3, Math.ceil(cells.length / 20)), mainColor(cells), 200); else addChunk(ev.id, cells, ev.w, ev.h, { id: ev.id, x: ev.x, y: ev.y, angle: 0 }); return; } // Merge: the sprite becomes terrain again. removeChunk(ev.id); if (!live) return; particles.puff(ev.x + ev.w / 2, ev.y + ev.h, 12); shake = Math.min(SHAKE_MAX, shake + MERGE_SHAKE); 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; 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( msg.reason === 'version' ? fr.rejectedVersion(msg.serverVersion, PROTOCOL_VERSION) : msg.reason === 'character' ? fr.rejectedCharacter : fr.rejectedFull, ); break; case 'playerJoined': infos.set(msg.player.id, msg.player); break; case 'playerLeft': infos.delete(msg.id); views.get(msg.id)?.destroy(); views.delete(msg.id); break; 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; case 'crushed': popups.spawn(msg.x, msg.y, fr.onomatopoeia.crush, 46); particles.burst(msg.x, msg.y, 24, PLAYER_SPLAT_COLOR, 500); shake = Math.min(SHAKE_MAX, shake + CRUSH_SHAKE); freezeUntil = performance.now() + FREEZE_FRAME_MS * 2; break; case 'snapshot': snapshots.push(performance.now(), msg.players, msg.chunks); break; } }; const conn = new Connection( `ws://${address}:${port}`, onMessage, () => conn.send(hello(choice.name, choice.characterId)), () => { setStatus(fr.disconnected); lobbyPanel.update('playing', [], null); // hide it }, ); const screenToWorld = (sx: number, sy: number): [number, number] => [ (sx - camera.x) / camera.scale.x - worldRoot.x, (sy - camera.y) / camera.scale.y - worldRoot.y, ]; setInterval(() => { const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY); conn.send({ t: 'input', buttons: input.buttons, aimX: Math.round(aimX), aimY: Math.round(aimY) }); }, 1000 / INPUT_SEND_HZ); let camX = 0; let camY = 0; let cameraReady = false; let fpsAcc = 0; let fpsFrames = 0; app.ticker.add((ticker) => { const dt = Math.min(0.05, ticker.deltaMS / 1000); fpsAcc += ticker.deltaMS / 1000; fpsFrames++; if (fpsAcc >= 0.5) { fpsEl.textContent = fr.fps(Math.round(fpsFrames / fpsAcc)); fpsAcc = 0; fpsFrames = 0; } vignette.width = app.screen.width; vignette.height = app.screen.height; if (!world) return; // Freeze-frame: hold the last image for a beat on big hits. if (performance.now() < freezeUntil) return; world.view.update(); particles.update(dt); popups.update(dt); popupCooldown -= dt; const sample = snapshots.sample(performance.now() - INTERPOLATION_DELAY_MS); states = sample.players; for (const [id, view] of chunkViews) { const s = sample.chunks.get(id); if (s) view.update(s); } const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY); for (const [id, state] of states) { const info = infos.get(id); if (!info) continue; let view = views.get(id); if (!view) { view = new PlayerView(info); views.set(id, view); playersLayer.addChild(view.container); } const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt); if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) particles.puff(state.x, state.y + state.size, 8); if (ev.stunnedNow) { popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40); particles.burst(state.x, state.y, 10, 0xffe14d, 300); shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE); freezeUntil = performance.now() + FREEZE_FRAME_MS; } } // Camera: follow the local player with a lead toward the cursor, clamped to the map, with screen shake. const scale = app.screen.height / CAMERA_VIEW_HEIGHT; camera.scale.set(scale); const me = states.get(localId); const viewW = app.screen.width / scale; const viewH = app.screen.height / scale; if (me) { const tx = me.x + (aimX - me.x) * CAMERA_AIM_LEAD - viewW / 2; const ty = me.y + (aimY - me.y) * CAMERA_AIM_LEAD - viewH / 2; const k = cameraReady ? Math.min(1, CAMERA_FOLLOW_SPEED * dt) : 1; cameraReady = true; camX += (tx - camX) * k; camY += (ty - camY) * k; } const { width, height } = world.terrain; const cx = viewW >= width ? (width - viewW) / 2 : Math.max(0, Math.min(width - viewW, camX)); const cy = viewH >= height ? (height - viewH) / 2 : Math.max(0, Math.min(height - viewH, camY)); shake = Math.max(0, shake - SHAKE_DECAY * dt); worldRoot.position.set(Math.round(-cx + (Math.random() - 0.5) * shake * 2), Math.round(-cy + (Math.random() - 0.5) * shake * 2)); }); } start().catch((err: unknown) => { console.error(err); setStatus(fr.loadError(err instanceof Error ? err.message : String(err))); });