main.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import 'pixi.js/unsafe-eval'; // Pixi without eval, required by our Content-Security-Policy
  2. import { Application, CanvasSource, Container, ImageSource, Sprite, Texture } from 'pixi.js';
  3. import { Anim, CHARACTER_IDS } from '../shared/characters';
  4. import { DEFAULT_CHARACTER_ID, INTERPOLATION_DELAY_MS, PROTOCOL_VERSION } from '../shared/constants';
  5. import { buildTerrain, parseMapDefinition } from '../shared/maps/loader';
  6. import { parseHexColor, parseMaterials, type MaterialRegistry } from '../shared/materials';
  7. import { hello, type ChunkInfo, type ChunkState, type PlayerInfo, type PlayerState, type ServerMessage } from '../shared/protocol';
  8. import type { TerrainBitmap } from '../shared/terrain/bitmap';
  9. import { applyTerrainEvent, type SequencedEvent, type TerrainEvent } from '../shared/terrain/events';
  10. import { rleDecode } from '../shared/terrain/rle';
  11. import {
  12. CAMERA_AIM_LEAD,
  13. CAMERA_FOLLOW_SPEED,
  14. CAMERA_VIEW_HEIGHT,
  15. CRUSH_SHAKE,
  16. FREEZE_FRAME_MS,
  17. HIT_SHAKE,
  18. INPUT_SEND_HZ,
  19. LANDING_DUST_MIN_VY,
  20. MERGE_SHAKE,
  21. PARTICLES_PER_100_PX,
  22. PLAYER_SPLAT_COLOR,
  23. POPUP_COOLDOWN,
  24. POPUP_MIN_PIXELS,
  25. SHAKE_DECAY,
  26. SHAKE_MAX,
  27. SHAKE_PER_PIXEL,
  28. } from './config';
  29. import { ChunkView } from './entities/ChunkView';
  30. import { PlayerView } from './entities/PlayerView';
  31. import { Particles } from './fx/Particles';
  32. import { Popups } from './fx/Popups';
  33. import { fr } from './i18n/fr';
  34. import { InputTracker } from './input';
  35. import { Connection } from './net/connection';
  36. import { SnapshotBuffer } from './net/interpolation';
  37. import { TerrainView } from './terrain/TerrainView';
  38. import { showBrowser } from './ui/browser';
  39. import { LobbyPanel } from './ui/lobbyPanel';
  40. import { showMenu, type MenuChoice } from './ui/menu';
  41. const PORTRAIT_RADIUS = 34;
  42. const EATING_POPUP_RANGE = 120;
  43. const statusEl = document.getElementById('status')!;
  44. const fpsEl = document.getElementById('fps')!;
  45. const helpEl = document.getElementById('help')!;
  46. const setStatus = (text: string): void => {
  47. statusEl.textContent = text;
  48. };
  49. /** Everything tied to one loaded map. Rebuilt when a match starts. */
  50. interface World {
  51. materials: MaterialRegistry;
  52. terrain: TerrainBitmap;
  53. view: TerrainView;
  54. background: Sprite;
  55. lastSeq: number;
  56. }
  57. async function loadWorld(mapId: string, materials: MaterialRegistry): Promise<World> {
  58. const files = await window.assets.map(mapId);
  59. const def = parseMapDefinition(files.definition, mapId);
  60. const terrain = buildTerrain(def, files.terrain, materials);
  61. const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
  62. const background = new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) }));
  63. return { materials, terrain, view: new TerrainView(terrain, materials), background, lastSeq: 0 };
  64. }
  65. /** Screen-space warm vignette drawn over the game. */
  66. function makeVignette(): Sprite {
  67. const canvas = document.createElement('canvas');
  68. canvas.width = 512;
  69. canvas.height = 512;
  70. const ctx = canvas.getContext('2d')!;
  71. const grad = ctx.createRadialGradient(256, 230, 140, 256, 256, 380);
  72. grad.addColorStop(0, 'rgba(255, 220, 160, 0)');
  73. grad.addColorStop(0.7, 'rgba(120, 60, 20, 0.12)');
  74. grad.addColorStop(1, 'rgba(59, 36, 20, 0.45)');
  75. ctx.fillStyle = grad;
  76. ctx.fillRect(0, 0, 512, 512);
  77. return new Sprite(new Texture({ source: new CanvasSource({ resource: canvas }) }));
  78. }
  79. /** Renders each character's idle pose to a canvas for the menu. */
  80. function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
  81. const out = new Map<string, HTMLCanvasElement>();
  82. CHARACTER_IDS.forEach((characterId, i) => {
  83. const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS });
  84. view.drawIdle();
  85. out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 2 }) as HTMLCanvasElement);
  86. view.destroy();
  87. });
  88. return out;
  89. }
  90. /** Title menu then game browser, until the player picks a game to host or join. */
  91. async function chooseGame(app: Application): Promise<{ choice: MenuChoice; address: string; port: number }> {
  92. const portraits = makePortraits(app);
  93. for (;;) {
  94. const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, portraits);
  95. const target = await showBrowser();
  96. if (target.mode === 'back') continue;
  97. if (target.mode === 'join') return { choice, address: target.address, port: target.port };
  98. setStatus(fr.connecting);
  99. const { port } = await window.lan.host(fr.browser.gameName(choice.name));
  100. return { choice, address: '127.0.0.1', port };
  101. }
  102. }
  103. async function start(): Promise<void> {
  104. const app = new Application();
  105. const host = document.getElementById('game')!;
  106. await app.init({ resizeTo: window, background: 0xfff1d6, antialias: true });
  107. host.appendChild(app.canvas);
  108. const { choice, address, port } = await chooseGame(app);
  109. helpEl.textContent = fr.help[choice.characterId] ?? '';
  110. setStatus(fr.connecting);
  111. const camera = new Container();
  112. const worldRoot = new Container();
  113. camera.addChild(worldRoot);
  114. const vignette = makeVignette();
  115. app.stage.addChild(camera, vignette);
  116. const particles = new Particles();
  117. const popups = new Popups();
  118. const playersLayer = new Container();
  119. const chunksLayer = new Container();
  120. // Map-independent layers always sit above the map ones (background, terrain).
  121. worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
  122. const materials = parseMaterials(await window.assets.materials());
  123. let world: World | null = null;
  124. /** Bumped on every map (re)load, so a slow load never installs a stale world. */
  125. let generation = 0;
  126. let localId = -1;
  127. let pendingEvents: SequencedEvent[] = [];
  128. /** Server hash waiting for this client to reach the same event seq. */
  129. let pendingHash: { seq: number; hash: number } | null = null;
  130. let infos = new Map<number, PlayerInfo>();
  131. const views = new Map<number, PlayerView>();
  132. const chunkViews = new Map<number, ChunkView>();
  133. let snapshots = new SnapshotBuffer();
  134. const input = new InputTracker(app.canvas);
  135. let states = new Map<number, PlayerState>();
  136. let shake = 0;
  137. let freezeUntil = 0;
  138. let popupCooldown = 0;
  139. const nearEater = (x: number, y: number): boolean => {
  140. for (const s of states.values()) {
  141. if (s.anim & Anim.Special && Math.hypot(s.x - x, s.y - y) < EATING_POPUP_RANGE) return true;
  142. }
  143. return false;
  144. };
  145. const addChunk = (id: number, mask: Uint8Array, w: number, h: number, state: ChunkState): void => {
  146. if (!world || chunkViews.has(id)) return;
  147. const view = new ChunkView(world.view.makeChunkTexture(mask, w, h, Math.round(state.x), Math.round(state.y)), state);
  148. chunkViews.set(id, view);
  149. chunksLayer.addChild(view.sprite);
  150. };
  151. const removeChunk = (id: number): void => {
  152. chunkViews.get(id)?.destroy();
  153. chunkViews.delete(id);
  154. };
  155. /** Most common material of a mask, for debris colors. */
  156. const mainColor = (cells: Uint8Array): number => {
  157. const counts = new Map<number, number>();
  158. for (const v of cells) if (v !== 0) counts.set(v, (counts.get(v) ?? 0) + 1);
  159. let best = 1;
  160. let bestN = -1;
  161. for (const [v, n] of counts) if (n > bestN) [best, bestN] = [v, n];
  162. const m = world?.materials.byId[best];
  163. return m ? parseHexColor(m.color) : 0xffffff;
  164. };
  165. /** Compares our bitmap with the server's once we have applied the same events (readme §9, M3). */
  166. const checkHash = (): void => {
  167. if (!world || !pendingHash || world.lastSeq !== pendingHash.seq) return;
  168. const mine = world.terrain.hash();
  169. if (mine !== pendingHash.hash) {
  170. console.error(`[net] DESYNC at seq ${pendingHash.seq}: server ${pendingHash.hash}, client ${mine}`);
  171. setStatus(fr.desync);
  172. conn.send({ t: 'desync', seq: pendingHash.seq, hash: mine });
  173. }
  174. pendingHash = null;
  175. };
  176. /**
  177. * Applies one terrain event in sequence. `live` is false while replaying history:
  178. * no effects, and no chunk sprites (chunks still falling come from the welcome message).
  179. */
  180. const applyServerEvent = (seq: number, ev: TerrainEvent, live: boolean): void => {
  181. if (!world) {
  182. pendingEvents.push({ seq, ev });
  183. return;
  184. }
  185. if (seq !== world.lastSeq + 1) console.error(`[net] terrain event out of order: got ${seq}, expected ${world.lastSeq + 1}`);
  186. world.lastSeq = seq;
  187. const { removedByMaterial } = applyTerrainEvent(world.terrain, ev, world.materials);
  188. world.view.onEvent(ev);
  189. checkHash();
  190. if (ev.kind === 'op') {
  191. if (!live) return;
  192. const { op } = ev;
  193. let total = 0;
  194. for (const [mat, count] of removedByMaterial) {
  195. total += count;
  196. const m = world.materials.byId[mat];
  197. if (m) particles.burst(op.x, op.y, Math.ceil((count / 100) * PARTICLES_PER_100_PX), parseHexColor(m.color));
  198. }
  199. shake = Math.min(SHAKE_MAX, shake + total * SHAKE_PER_PIXEL);
  200. if (total >= POPUP_MIN_PIXELS && popupCooldown <= 0) {
  201. popupCooldown = POPUP_COOLDOWN;
  202. popups.spawn(op.x, op.y - op.r, nearEater(op.x, op.y) ? fr.onomatopoeia.munch : fr.onomatopoeia.crunch);
  203. }
  204. return;
  205. }
  206. const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, ev.w * ev.h);
  207. if (ev.kind === 'detach') {
  208. if (!live) return;
  209. 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);
  210. else addChunk(ev.id, cells, ev.w, ev.h, { id: ev.id, x: ev.x, y: ev.y, angle: 0 });
  211. return;
  212. }
  213. // Merge: the sprite becomes terrain again.
  214. removeChunk(ev.id);
  215. if (!live) return;
  216. particles.puff(ev.x + ev.w / 2, ev.y + ev.h, 12);
  217. shake = Math.min(SHAKE_MAX, shake + MERGE_SHAKE);
  218. popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
  219. };
  220. /** Drops the current map and everything on it, then loads `mapId` and replays `history`. */
  221. const resetWorld = (mapId: string, history: SequencedEvent[], chunks: ChunkInfo[]): void => {
  222. const gen = ++generation;
  223. if (world) {
  224. world.background.destroy({ texture: true, textureSource: true });
  225. world.view.container.destroy({ children: true, texture: true, textureSource: true });
  226. }
  227. world = null;
  228. pendingEvents = [];
  229. pendingHash = null;
  230. for (const id of [...chunkViews.keys()]) removeChunk(id);
  231. for (const view of views.values()) view.destroy();
  232. views.clear();
  233. snapshots = new SnapshotBuffer();
  234. setStatus(fr.loadingMap);
  235. void loadWorld(mapId, materials)
  236. .then((w) => {
  237. if (gen !== generation) return; // superseded by a newer start
  238. world = w;
  239. worldRoot.addChildAt(w.background, 0);
  240. worldRoot.addChildAt(w.view.container, 1);
  241. for (const { seq, ev } of history) applyServerEvent(seq, ev, false);
  242. for (const c of chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
  243. const live = pendingEvents;
  244. pendingEvents = [];
  245. for (const { seq, ev } of live) applyServerEvent(seq, ev, true);
  246. setStatus('');
  247. })
  248. .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
  249. };
  250. const lobbyPanel = new LobbyPanel(
  251. () => localId,
  252. (ready) => conn.send({ t: 'ready', ready }),
  253. );
  254. const onMessage = (msg: ServerMessage): void => {
  255. switch (msg.t) {
  256. case 'welcome':
  257. localId = msg.playerId;
  258. infos = new Map(msg.players.map((p) => [p.id, p]));
  259. resetWorld(msg.mapId, msg.events, msg.chunks);
  260. break;
  261. case 'start':
  262. infos = new Map(msg.players.map((p) => [p.id, p]));
  263. resetWorld(msg.mapId, [], []);
  264. setStatus(fr.lobby.started);
  265. setTimeout(() => {
  266. if (statusEl.textContent === fr.lobby.started) setStatus('');
  267. }, 1500);
  268. break;
  269. case 'lobby':
  270. lobbyPanel.update(msg.phase, msg.players, msg.countdown);
  271. break;
  272. case 'reject':
  273. setStatus(
  274. msg.reason === 'version'
  275. ? fr.rejectedVersion(msg.serverVersion, PROTOCOL_VERSION)
  276. : msg.reason === 'character'
  277. ? fr.rejectedCharacter
  278. : fr.rejectedFull,
  279. );
  280. break;
  281. case 'playerJoined':
  282. infos.set(msg.player.id, msg.player);
  283. break;
  284. case 'playerLeft':
  285. infos.delete(msg.id);
  286. views.get(msg.id)?.destroy();
  287. views.delete(msg.id);
  288. break;
  289. case 'terrain':
  290. applyServerEvent(msg.seq, msg.ev, true);
  291. break;
  292. case 'hash':
  293. pendingHash = { seq: msg.seq, hash: msg.hash };
  294. checkHash();
  295. break;
  296. case 'chunkRemoved':
  297. removeChunk(msg.id);
  298. break;
  299. case 'crushed':
  300. popups.spawn(msg.x, msg.y, fr.onomatopoeia.crush, 46);
  301. particles.burst(msg.x, msg.y, 24, PLAYER_SPLAT_COLOR, 500);
  302. shake = Math.min(SHAKE_MAX, shake + CRUSH_SHAKE);
  303. freezeUntil = performance.now() + FREEZE_FRAME_MS * 2;
  304. break;
  305. case 'snapshot':
  306. snapshots.push(performance.now(), msg.players, msg.chunks);
  307. break;
  308. }
  309. };
  310. const conn = new Connection(
  311. `ws://${address}:${port}`,
  312. onMessage,
  313. () => conn.send(hello(choice.name, choice.characterId)),
  314. () => {
  315. setStatus(fr.disconnected);
  316. lobbyPanel.update('playing', [], null); // hide it
  317. },
  318. );
  319. const screenToWorld = (sx: number, sy: number): [number, number] => [
  320. (sx - camera.x) / camera.scale.x - worldRoot.x,
  321. (sy - camera.y) / camera.scale.y - worldRoot.y,
  322. ];
  323. setInterval(() => {
  324. const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
  325. conn.send({ t: 'input', buttons: input.buttons, aimX: Math.round(aimX), aimY: Math.round(aimY) });
  326. }, 1000 / INPUT_SEND_HZ);
  327. let camX = 0;
  328. let camY = 0;
  329. let cameraReady = false;
  330. let fpsAcc = 0;
  331. let fpsFrames = 0;
  332. app.ticker.add((ticker) => {
  333. const dt = Math.min(0.05, ticker.deltaMS / 1000);
  334. fpsAcc += ticker.deltaMS / 1000;
  335. fpsFrames++;
  336. if (fpsAcc >= 0.5) {
  337. fpsEl.textContent = fr.fps(Math.round(fpsFrames / fpsAcc));
  338. fpsAcc = 0;
  339. fpsFrames = 0;
  340. }
  341. vignette.width = app.screen.width;
  342. vignette.height = app.screen.height;
  343. if (!world) return;
  344. // Freeze-frame: hold the last image for a beat on big hits.
  345. if (performance.now() < freezeUntil) return;
  346. world.view.update();
  347. particles.update(dt);
  348. popups.update(dt);
  349. popupCooldown -= dt;
  350. const sample = snapshots.sample(performance.now() - INTERPOLATION_DELAY_MS);
  351. states = sample.players;
  352. for (const [id, view] of chunkViews) {
  353. const s = sample.chunks.get(id);
  354. if (s) view.update(s);
  355. }
  356. const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
  357. for (const [id, state] of states) {
  358. const info = infos.get(id);
  359. if (!info) continue;
  360. let view = views.get(id);
  361. if (!view) {
  362. view = new PlayerView(info);
  363. views.set(id, view);
  364. playersLayer.addChild(view.container);
  365. }
  366. const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt);
  367. if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) particles.puff(state.x, state.y + state.size, 8);
  368. if (ev.stunnedNow) {
  369. popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40);
  370. particles.burst(state.x, state.y, 10, 0xffe14d, 300);
  371. shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);
  372. freezeUntil = performance.now() + FREEZE_FRAME_MS;
  373. }
  374. }
  375. // Camera: follow the local player with a lead toward the cursor, clamped to the map, with screen shake.
  376. const scale = app.screen.height / CAMERA_VIEW_HEIGHT;
  377. camera.scale.set(scale);
  378. const me = states.get(localId);
  379. const viewW = app.screen.width / scale;
  380. const viewH = app.screen.height / scale;
  381. if (me) {
  382. const tx = me.x + (aimX - me.x) * CAMERA_AIM_LEAD - viewW / 2;
  383. const ty = me.y + (aimY - me.y) * CAMERA_AIM_LEAD - viewH / 2;
  384. const k = cameraReady ? Math.min(1, CAMERA_FOLLOW_SPEED * dt) : 1;
  385. cameraReady = true;
  386. camX += (tx - camX) * k;
  387. camY += (ty - camY) * k;
  388. }
  389. const { width, height } = world.terrain;
  390. const cx = viewW >= width ? (width - viewW) / 2 : Math.max(0, Math.min(width - viewW, camX));
  391. const cy = viewH >= height ? (height - viewH) / 2 : Math.max(0, Math.min(height - viewH, camY));
  392. shake = Math.max(0, shake - SHAKE_DECAY * dt);
  393. worldRoot.position.set(Math.round(-cx + (Math.random() - 0.5) * shake * 2), Math.round(-cy + (Math.random() - 0.5) * shake * 2));
  394. });
  395. }
  396. start().catch((err: unknown) => {
  397. console.error(err);
  398. setStatus(fr.loadError(err instanceof Error ? err.message : String(err)));
  399. });