main.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  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 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 { showMenu } from './ui/menu';
  39. const PORTRAIT_RADIUS = 34;
  40. const EATING_POPUP_RANGE = 120;
  41. const statusEl = document.getElementById('status')!;
  42. const fpsEl = document.getElementById('fps')!;
  43. const helpEl = document.getElementById('help')!;
  44. const setStatus = (text: string): void => {
  45. statusEl.textContent = text;
  46. };
  47. interface World {
  48. materials: MaterialRegistry;
  49. terrain: TerrainBitmap;
  50. view: TerrainView;
  51. lastSeq: number;
  52. }
  53. async function loadWorld(mapId: string, materials: MaterialRegistry, root: Container): Promise<World> {
  54. const files = await window.assets.map(mapId);
  55. const def = parseMapDefinition(files.definition, mapId);
  56. const terrain = buildTerrain(def, files.terrain, materials);
  57. const bitmap = await createImageBitmap(new Blob([files.background as BlobPart], { type: 'image/png' }));
  58. root.addChild(new Sprite(new Texture({ source: new ImageSource({ resource: bitmap }) })));
  59. const view = new TerrainView(terrain, materials);
  60. root.addChild(view.container);
  61. return { materials, terrain, view, lastSeq: 0 };
  62. }
  63. /** Screen-space warm vignette drawn over the game. */
  64. function makeVignette(): Sprite {
  65. const canvas = document.createElement('canvas');
  66. canvas.width = 512;
  67. canvas.height = 512;
  68. const ctx = canvas.getContext('2d')!;
  69. const grad = ctx.createRadialGradient(256, 230, 140, 256, 256, 380);
  70. grad.addColorStop(0, 'rgba(255, 220, 160, 0)');
  71. grad.addColorStop(0.7, 'rgba(120, 60, 20, 0.12)');
  72. grad.addColorStop(1, 'rgba(59, 36, 20, 0.45)');
  73. ctx.fillStyle = grad;
  74. ctx.fillRect(0, 0, 512, 512);
  75. return new Sprite(new Texture({ source: new CanvasSource({ resource: canvas }) }));
  76. }
  77. /** Renders each character's idle pose to a canvas for the menu. */
  78. function makePortraits(app: Application): Map<string, HTMLCanvasElement> {
  79. const out = new Map<string, HTMLCanvasElement>();
  80. CHARACTER_IDS.forEach((characterId, i) => {
  81. const view = new PlayerView({ id: i + 1, name: '', characterId, radius: PORTRAIT_RADIUS });
  82. view.drawIdle();
  83. out.set(characterId, app.renderer.extract.canvas({ target: view.container, resolution: 2 }) as HTMLCanvasElement);
  84. view.destroy();
  85. });
  86. return out;
  87. }
  88. async function start(): Promise<void> {
  89. const app = new Application();
  90. const host = document.getElementById('game')!;
  91. await app.init({ resizeTo: window, background: 0xfff1d6, antialias: true });
  92. host.appendChild(app.canvas);
  93. const choice = await showMenu(CHARACTER_IDS, DEFAULT_CHARACTER_ID, makePortraits(app));
  94. helpEl.textContent = fr.help[choice.characterId] ?? '';
  95. const camera = new Container();
  96. const worldRoot = new Container();
  97. camera.addChild(worldRoot);
  98. const vignette = makeVignette();
  99. app.stage.addChild(camera, vignette);
  100. const particles = new Particles();
  101. const popups = new Popups();
  102. const playersLayer = new Container();
  103. const chunksLayer = new Container();
  104. setStatus(fr.connecting);
  105. const { port } = await window.lan.host();
  106. const materials = parseMaterials(await window.assets.materials());
  107. let world: World | null = null;
  108. let localId = -1;
  109. let pendingEvents: SequencedEvent[] = [];
  110. const infos = new Map<number, PlayerInfo>();
  111. const views = new Map<number, PlayerView>();
  112. const chunkViews = new Map<number, ChunkView>();
  113. const snapshots = new SnapshotBuffer();
  114. const input = new InputTracker(app.canvas);
  115. let states = new Map<number, PlayerState>();
  116. let shake = 0;
  117. let freezeUntil = 0;
  118. let popupCooldown = 0;
  119. const nearEater = (x: number, y: number): boolean => {
  120. for (const s of states.values()) {
  121. if (s.anim & Anim.Special && Math.hypot(s.x - x, s.y - y) < EATING_POPUP_RANGE) return true;
  122. }
  123. return false;
  124. };
  125. const addChunk = (id: number, mask: Uint8Array, w: number, h: number, state: ChunkState): void => {
  126. if (!world || chunkViews.has(id)) return;
  127. const view = new ChunkView(world.view.makeChunkTexture(mask, w, h, Math.round(state.x), Math.round(state.y)), state);
  128. chunkViews.set(id, view);
  129. chunksLayer.addChild(view.sprite);
  130. };
  131. const removeChunk = (id: number): void => {
  132. chunkViews.get(id)?.destroy();
  133. chunkViews.delete(id);
  134. };
  135. /** Most common material of a mask, for debris colors. */
  136. const mainColor = (cells: Uint8Array): number => {
  137. const counts = new Map<number, number>();
  138. for (const v of cells) if (v !== 0) counts.set(v, (counts.get(v) ?? 0) + 1);
  139. let best = 1;
  140. let bestN = -1;
  141. for (const [v, n] of counts) if (n > bestN) [best, bestN] = [v, n];
  142. const m = world?.materials.byId[best];
  143. return m ? parseHexColor(m.color) : 0xffffff;
  144. };
  145. /**
  146. * Applies one terrain event in sequence. `live` is false while replaying history:
  147. * no effects, and no chunk sprites (chunks still falling come from the welcome message).
  148. */
  149. const applyServerEvent = (seq: number, ev: TerrainEvent, live: boolean): void => {
  150. if (!world) {
  151. pendingEvents.push({ seq, ev });
  152. return;
  153. }
  154. if (seq !== world.lastSeq + 1) console.error(`[net] terrain event out of order: got ${seq}, expected ${world.lastSeq + 1}`);
  155. world.lastSeq = seq;
  156. const { removedByMaterial } = applyTerrainEvent(world.terrain, ev, world.materials);
  157. world.view.onEvent(ev);
  158. if (ev.kind === 'op') {
  159. if (!live) return;
  160. const { op } = ev;
  161. let total = 0;
  162. for (const [mat, count] of removedByMaterial) {
  163. total += count;
  164. const m = world.materials.byId[mat];
  165. if (m) particles.burst(op.x, op.y, Math.ceil((count / 100) * PARTICLES_PER_100_PX), parseHexColor(m.color));
  166. }
  167. shake = Math.min(SHAKE_MAX, shake + total * SHAKE_PER_PIXEL);
  168. if (total >= POPUP_MIN_PIXELS && popupCooldown <= 0) {
  169. popupCooldown = POPUP_COOLDOWN;
  170. popups.spawn(op.x, op.y - op.r, nearEater(op.x, op.y) ? fr.onomatopoeia.munch : fr.onomatopoeia.crunch);
  171. }
  172. return;
  173. }
  174. const cells = rleDecode(ev.kind === 'detach' ? ev.mask : ev.patch, ev.w * ev.h);
  175. if (ev.kind === 'detach') {
  176. if (!live) return;
  177. 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);
  178. else addChunk(ev.id, cells, ev.w, ev.h, { id: ev.id, x: ev.x, y: ev.y, angle: 0 });
  179. return;
  180. }
  181. // Merge: the sprite becomes terrain again.
  182. removeChunk(ev.id);
  183. if (!live) return;
  184. particles.puff(ev.x + ev.w / 2, ev.y + ev.h, 12);
  185. shake = Math.min(SHAKE_MAX, shake + MERGE_SHAKE);
  186. popups.spawn(ev.x + ev.w / 2, ev.y, fr.onomatopoeia.thud, 30);
  187. };
  188. const onMessage = (msg: ServerMessage): void => {
  189. switch (msg.t) {
  190. case 'welcome':
  191. localId = msg.playerId;
  192. msg.players.forEach((p) => infos.set(p.id, p));
  193. setStatus(fr.loadingMap);
  194. void loadWorld(msg.mapId, materials, worldRoot)
  195. .then((w) => {
  196. world = w;
  197. worldRoot.addChild(chunksLayer, playersLayer, particles.graphics, popups.container);
  198. for (const { seq, ev } of msg.events) applyServerEvent(seq, ev, false);
  199. for (const c of msg.chunks) addChunk(c.id, rleDecode(c.mask, c.w * c.h), c.w, c.h, c.state);
  200. const live = pendingEvents;
  201. pendingEvents = [];
  202. for (const { seq, ev } of live) applyServerEvent(seq, ev, true);
  203. setStatus('');
  204. })
  205. .catch((err: unknown) => setStatus(fr.loadError(err instanceof Error ? err.message : String(err))));
  206. break;
  207. case 'reject':
  208. setStatus(
  209. msg.reason === 'version'
  210. ? fr.rejectedVersion(msg.serverVersion, PROTOCOL_VERSION)
  211. : msg.reason === 'character'
  212. ? fr.rejectedCharacter
  213. : fr.rejectedFull,
  214. );
  215. break;
  216. case 'playerJoined':
  217. infos.set(msg.player.id, msg.player);
  218. break;
  219. case 'playerLeft':
  220. infos.delete(msg.id);
  221. views.get(msg.id)?.destroy();
  222. views.delete(msg.id);
  223. break;
  224. case 'terrain':
  225. applyServerEvent(msg.seq, msg.ev, true);
  226. break;
  227. case 'chunkRemoved':
  228. removeChunk(msg.id);
  229. break;
  230. case 'crushed':
  231. popups.spawn(msg.x, msg.y, fr.onomatopoeia.crush, 46);
  232. particles.burst(msg.x, msg.y, 24, PLAYER_SPLAT_COLOR, 500);
  233. shake = Math.min(SHAKE_MAX, shake + CRUSH_SHAKE);
  234. freezeUntil = performance.now() + FREEZE_FRAME_MS * 2;
  235. break;
  236. case 'snapshot':
  237. snapshots.push(performance.now(), msg.players, msg.chunks);
  238. break;
  239. }
  240. };
  241. const conn = new Connection(
  242. `ws://127.0.0.1:${port}`,
  243. onMessage,
  244. () => conn.send(hello(choice.name, choice.characterId)),
  245. () => setStatus(fr.disconnected),
  246. );
  247. const screenToWorld = (sx: number, sy: number): [number, number] => [
  248. (sx - camera.x) / camera.scale.x - worldRoot.x,
  249. (sy - camera.y) / camera.scale.y - worldRoot.y,
  250. ];
  251. setInterval(() => {
  252. const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
  253. conn.send({ t: 'input', buttons: input.buttons, aimX: Math.round(aimX), aimY: Math.round(aimY) });
  254. }, 1000 / INPUT_SEND_HZ);
  255. let camX = 0;
  256. let camY = 0;
  257. let cameraReady = false;
  258. let fpsAcc = 0;
  259. let fpsFrames = 0;
  260. app.ticker.add((ticker) => {
  261. const dt = Math.min(0.05, ticker.deltaMS / 1000);
  262. fpsAcc += ticker.deltaMS / 1000;
  263. fpsFrames++;
  264. if (fpsAcc >= 0.5) {
  265. fpsEl.textContent = fr.fps(Math.round(fpsFrames / fpsAcc));
  266. fpsAcc = 0;
  267. fpsFrames = 0;
  268. }
  269. vignette.width = app.screen.width;
  270. vignette.height = app.screen.height;
  271. if (!world) return;
  272. // Freeze-frame: hold the last image for a beat on big hits.
  273. if (performance.now() < freezeUntil) return;
  274. world.view.update();
  275. particles.update(dt);
  276. popups.update(dt);
  277. popupCooldown -= dt;
  278. const sample = snapshots.sample(performance.now() - INTERPOLATION_DELAY_MS);
  279. states = sample.players;
  280. for (const [id, view] of chunkViews) {
  281. const s = sample.chunks.get(id);
  282. if (s) view.update(s);
  283. }
  284. const [aimX, aimY] = screenToWorld(input.mouseX, input.mouseY);
  285. for (const [id, state] of states) {
  286. const info = infos.get(id);
  287. if (!info) continue;
  288. let view = views.get(id);
  289. if (!view) {
  290. view = new PlayerView(info);
  291. views.set(id, view);
  292. playersLayer.addChild(view.container);
  293. }
  294. const ev = view.update(state, id === localId ? aimX : state.x + state.facing * 100, id === localId ? aimY : state.y, dt);
  295. if (ev.landed && ev.landingSpeed > LANDING_DUST_MIN_VY) particles.puff(state.x, state.y + state.size, 8);
  296. if (ev.stunnedNow) {
  297. popups.spawn(state.x, state.y - state.size, fr.onomatopoeia.hit, 40);
  298. particles.burst(state.x, state.y, 10, 0xffe14d, 300);
  299. shake = Math.min(SHAKE_MAX, shake + HIT_SHAKE);
  300. freezeUntil = performance.now() + FREEZE_FRAME_MS;
  301. }
  302. }
  303. // Camera: follow the local player with a lead toward the cursor, clamped to the map, with screen shake.
  304. const scale = app.screen.height / CAMERA_VIEW_HEIGHT;
  305. camera.scale.set(scale);
  306. const me = states.get(localId);
  307. const viewW = app.screen.width / scale;
  308. const viewH = app.screen.height / scale;
  309. if (me) {
  310. const tx = me.x + (aimX - me.x) * CAMERA_AIM_LEAD - viewW / 2;
  311. const ty = me.y + (aimY - me.y) * CAMERA_AIM_LEAD - viewH / 2;
  312. const k = cameraReady ? Math.min(1, CAMERA_FOLLOW_SPEED * dt) : 1;
  313. cameraReady = true;
  314. camX += (tx - camX) * k;
  315. camY += (ty - camY) * k;
  316. }
  317. const { width, height } = world.terrain;
  318. const cx = viewW >= width ? (width - viewW) / 2 : Math.max(0, Math.min(width - viewW, camX));
  319. const cy = viewH >= height ? (height - viewH) / 2 : Math.max(0, Math.min(height - viewH, camY));
  320. shake = Math.max(0, shake - SHAKE_DECAY * dt);
  321. worldRoot.position.set(Math.round(-cx + (Math.random() - 0.5) * shake * 2), Math.round(-cy + (Math.random() - 0.5) * shake * 2));
  322. });
  323. }
  324. start().catch((err: unknown) => {
  325. console.error(err);
  326. setStatus(fr.loadError(err instanceof Error ? err.message : String(err)));
  327. });