|
@@ -0,0 +1,356 @@
|
|
|
|
|
+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)));
|
|
|
|
|
+});
|