| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- 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 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 { showMenu } 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;
- };
- interface World {
- materials: MaterialRegistry;
- terrain: TerrainBitmap;
- view: TerrainView;
- lastSeq: number;
- }
- async function loadWorld(mapId: string, materials: MaterialRegistry, root: Container): 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 };
- }
- /** 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<string, HTMLCanvasElement> {
- const out = new Map<string, HTMLCanvasElement>();
- 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;
- }
- 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));
- helpEl.textContent = fr.help[choice.characterId] ?? '';
- 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();
- setStatus(fr.connecting);
- const { port } = await window.lan.host();
- const materials = parseMaterials(await window.assets.materials());
- let world: World | null = null;
- let localId = -1;
- let pendingEvents: SequencedEvent[] = [];
- const infos = new Map<number, PlayerInfo>();
- const views = new Map<number, PlayerView>();
- const chunkViews = new Map<number, ChunkView>();
- const snapshots = new SnapshotBuffer();
- const input = new InputTracker(app.canvas);
- let states = new Map<number, PlayerState>();
- 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<number, number>();
- 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;
- };
- /**
- * 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);
- 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);
- };
- 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))));
- 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 '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://127.0.0.1:${port}`,
- onMessage,
- () => conn.send(hello(choice.name, choice.characterId)),
- () => setStatus(fr.disconnected),
- );
- 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)));
- });
|