// main.js — the router. 64 hops, one TTL, no way back up except through. import * as THREE from '../vendor/three.module.js'; import * as A from './audio.js'; import * as In from './input.js'; import * as HUD from './hud.js'; import { Link } from './link.js'; import { World } from './kit.js'; import coldboot from './levels/coldboot.js'; import stack from './levels/stack.js'; import timewait from './levels/timewait.js'; import war from './levels/war.js'; import smash from './levels/smash.js'; import worm from './levels/worm.js'; import cuckoo from './levels/cuckoo.js'; import board from './levels/board.js'; import handshake from './levels/handshake.js'; import copper from './levels/copper.js'; import bluebox from './levels/bluebox.js'; import climb from './levels/climb.js'; import root from './levels/root.js'; export const ORDER = [ coldboot, stack, timewait, war, smash, worm, cuckoo, board, handshake, copper, bluebox, climb, root, ]; const BY_ID = Object.fromEntries(ORDER.map(l => [l.id, l])); const canvas = document.getElementById('gl'); const termCanvas = document.getElementById('term'); const renderer = new THREE.WebGLRenderer({ canvas, antialias: false, powerPreference: 'high-performance' }); renderer.setClearColor(0x000000, 1); renderer.autoClear = true; const link = new Link(renderer); In.attach(canvas); HUD.boot(); // ───────────────────────────────────────────────────────────── game state export const game = { ttl: 64, hop: 1, level: null, flags: Object.create(null), // what you learned, what you carry, who you met philes: new Set(), // the collectibles. the writing lives here. cargo: [], // BBS files. affects MTU. mtu: 1500, deaths: 0, reached: new Set(['coldboot']), paused: false, time: 0, }; // dev handle. HS.run(n) drives n frames by hand when rAF is asleep. window.HS = { game, link, A, In, HUD, THREE, goto: (id) => goto(id), run(n = 60, dt = 1 / 60) { for (let i = 0; i < n; i++) step(dt); }, }; function refreshHud() { HUD.status({ ttl: game.ttl, hop: game.hop, hops: 64, link: link.label, extra: game.cargo.length ? `MTU ${game.mtu} · ${game.cargo.length} file${game.cargo.length > 1 ? 's' : ''}` : '', }); } // TTL only ever goes down. There is no item for this. export function spendTTL(n = 1, why = '') { game.ttl -= n; refreshHud(); if (why) HUD.say(`${why}  −${n} TTL`, 1800); if (game.ttl <= 0) expire(); return game.ttl; } let expiring = false; async function expire() { if (expiring) return; expiring = true; game.deaths++; A.noCarrier(); link.glitch = 0.9; await HUD.flash('NO CARRIER', 'nc', 2400); link.glitch = 0; // ICMP Time Exceeded is sent to where you came from. It always is. HUD.say('icmp time exceeded — in transit', 2600); game.ttl = Math.max(12, 65 - game.hop); expiring = false; await goto(game.level.id, { restart: true }); } export { expire }; // ────────────────────────────────────────────────────────────── the loop const ctx = { THREE, A, In, HUD, link, game, renderer, scene: null, camera: null, world: null, term: null, termCanvas, spendTTL, refreshHud: () => refreshHud(), setBaud(i) { link.setBaud(i); refreshHud(); return link.spec; }, goto: (id, o) => goto(id, o), next: () => { const i = ORDER.indexOf(game.level); return goto(ORDER[Math.min(ORDER.length - 1, i + 1)].id); }, say: HUD.say, speak: HUD.speak, hint: HUD.hint, card: HUD.card, sleep: HUD.sleep, hush: HUD.hush, flash: HUD.flash, expire: () => expire(), phile(id, title, body) { if (game.philes.has(id)) return false; game.philes.add(id); HUD.say(`phile ${title} [${game.philes.size}/200]`, 2600); A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70); phileLog.push({ id, title, body }); return true; }, }; const phileLog = []; window.HS.philes = phileLog; window.HS.ctx = ctx; function disposeScene(s) { if (!s) return; s.traverse(o => { if (o.geometry) o.geometry.dispose(); const mats = o.material ? (Array.isArray(o.material) ? o.material : [o.material]) : []; for (const m of mats) { for (const k of ['map', 'alphaMap', 'emissiveMap']) if (m[k] && m[k].dispose && !m[k].__keep) m[k].dispose(); m.dispose(); } }); } let switching = false; export async function goto(id, { restart = false } = {}) { if (switching) return; switching = true; const nextLevel = BY_ID[id]; if (!nextLevel) { console.warn('no level', id); switching = false; return; } HUD.hint(''); HUD.hush(); await HUD.veil(1, 420); A.stopPad(); if (game.level && game.level.unmount) { try { await game.level.unmount(ctx); } catch (e) { console.error(e); } } In.releaseText(); In.clearRemap(); In.unlock(); termCanvas.style.display = 'none'; disposeScene(ctx.scene); ctx.scene = new THREE.Scene(); ctx.camera = new THREE.PerspectiveCamera(72, link.aspect, 0.05, 4000); ctx.world = new World(); game.level = nextLevel; game.reached.add(id); if (!restart) game.hop = nextLevel.hop; game.ttl = Math.max(1, Math.min(game.ttl, 65 - game.hop)); if (nextLevel.baud !== undefined && nextLevel.baud !== null) link.setBaud(nextLevel.baud); link.noTint(); if (nextLevel.tint) link.tint(nextLevel.tint[0], nextLevel.tint[1]); document.body.classList.toggle('ink-dark', nextLevel.ink === 'dark'); refreshHud(); HUD.showHud(nextLevel.hud !== false); // A level builds its scene synchronously and only then awaits its intro card, so // start lifting the veil as soon as mount is under way — otherwise the player // stares at a black screen for the length of the card that is playing behind it. const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })(); HUD.veil(0, 520); await mounting; switching = false; } let last = performance.now(); function frame(now) { requestAnimationFrame(frame); const dt = Math.min(0.05, (now - last) / 1000); last = now; step(dt); } // One tick, separated from rAF so it can be driven by hand — rAF is throttled to // zero in a hidden tab, which makes the game look broken when it is only asleep. export function step(dt) { game.time += dt; if (In.hit('Escape')) { In.unlock(); } if (In.hit('KeyM') && !In.hasTextCapture()) toggleMap(); if (In.hit('Backquote')) toggleDev(); if (!game.paused && game.level && game.level.update) { try { game.level.update(dt, game.time, ctx); } catch (e) { console.error(e); game.level.update = null; } } if (ctx.term && termCanvas.style.display !== 'none') ctx.term.draw(game.time); if (ctx.scene && ctx.camera) link.render(ctx.scene, ctx.camera, game.time); In.endFrame(); } addEventListener('resize', () => { link.resize(); if (ctx.camera && ctx.camera.isPerspectiveCamera) { ctx.camera.aspect = link.aspect; ctx.camera.updateProjectionMatrix(); } if (ctx.term) ctx.term.resize(); }); // ─────────────────────────────────────────────────── traceroute level select const mapEl = document.getElementById('map'); let mapOpen = false; function toggleMap() { mapOpen = !mapOpen; game.paused = mapOpen; mapEl.style.display = mapOpen ? 'flex' : 'none'; if (mapOpen) { In.unlock(); const rows = ORDER.map((l, i) => { const seen = game.reached.has(l.id); const here = game.level === l; const hopn = String(l.hop).padStart(2, '0'); const name = seen ? l.title : '* * *'; const via = seen ? l.via : ''; return `
${hopn} ${name} ${via || ''} ${seen ? (l.ms || (12 + i * 17) + ' ms') : ''}
`; }).join(''); mapEl.innerHTML = `
traceroute to root (0.0.0.0), 64 hops max, 1500 byte packets
${rows}
TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries
click a reached hop to return · M to close
`; mapEl.querySelectorAll('.row.seen').forEach(r => r.addEventListener('click', () => { const id = r.dataset.id; toggleMap(); goto(id); })); } } // ─────────────────────────────────────────────────────────────── dev const devEl = document.getElementById('dev'); let devOpen = false; function toggleDev() { devOpen = !devOpen; devEl.style.display = devOpen ? 'block' : 'none'; if (devOpen) { devEl.innerHTML = ORDER.map((l, i) => ``).join('') + `
baud: ${[0,1,2,3,4,5,6].map(b => ``).join('')}
`; devEl.querySelectorAll('[data-id]').forEach(b => b.onclick = () => { toggleDev(); goto(b.dataset.id); }); devEl.querySelectorAll('[data-baud]').forEach(b => b.onclick = () => { link.setBaud(+b.dataset.baud); refreshHud(); }); } } // ─────────────────────────────────────────────────────────────── boot const bootEl = document.getElementById('boot'); async function start() { bootEl.style.display = 'none'; A.boot(); A.resume(); requestAnimationFrame(frame); await goto('coldboot'); } bootEl.addEventListener('click', start); addEventListener('keydown', function once(e) { if (bootEl.style.display === 'none') { removeEventListener('keydown', once); return; } if (e.key === 'Tab') return; removeEventListener('keydown', once); start(); }); // carrier detect blinking on the boot screen, because of course { const cd = document.getElementById('cd'); setInterval(() => { if (cd) cd.style.opacity = cd.style.opacity === '0.15' ? '1' : '0.15'; }, 620); }