// 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 { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.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' : ''}` : '',
});
}
// How many pieces you arrive in. Two files is free; after that the network starts
// charging you for the privilege of being interesting.
export function fragments() {
game.mtu = Math.max(400, 1500 - game.cargo.length * 180);
if (game.cargo.length <= 2) return 1;
return 1 + Math.ceil((game.cargo.length - 2) / 3);
}
let fragNote = null;
// 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);
const p = phileLookup(id, title, body);
HUD.say(`phile ${p.title} [${game.philes.size}/${PHILE_TOTAL}]P to read`, 3000);
A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70);
phileLog.push(p);
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();
ctx.splitCam = null;
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));
// ── MTU. Carry too much and you do not fit down the next hop in one piece.
// Everything you took off the board is lore, and lore is paid for in distance.
if (!restart) {
const frags = fragments();
if (frags > 1) {
game.ttl -= (frags - 1);
fragNote = `fragmented ${game.mtu} byte MTU · ${frags} pieces · −${frags - 1} TTL`;
if (game.ttl <= 0) { expire(); switching = false; return; }
} else fragNote = null;
}
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;
refreshHud();
saveGame();
if (fragNote) { HUD.say(fragNote, 3600); fragNote = null; }
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 (readerOpen) toggleReader(); }
if (In.hit('KeyM') && !In.hasTextCapture()) toggleMap();
if (In.hit('KeyP') && !In.hasTextCapture()) toggleReader();
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) {
// a level can ask for two viewports by setting ctx.splitCam
if (ctx.splitCam) link.renderSplit(ctx.scene, ctx.camera, ctx.splitCam, game.time);
else 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 `
carrying ${game.cargo.length} · MTU ${game.mtu} · ${frags > 1
? `fragmenting into ${frags}, −${frags - 1} TTL every hop`
: 'fits in one piece'} ` +
game.cargo.map((f, i) => `${f} ✕`).join(' ') + '
'
: '';
mapEl.innerHTML = `
traceroute to root (0.0.0.0), 64 hops max, ${game.mtu} byte packets
${rows}
${cargo}
TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries
click a reached hop to return · P for philes · M to close
`;
mapEl.querySelectorAll('.row.seen').forEach(r => r.addEventListener('click', () => {
const id = r.dataset.id;
toggleMap();
goto(id);
}));
// you can put something down. that is the whole trade.
mapEl.querySelectorAll('.drop').forEach(el => el.addEventListener('click', () => {
game.cargo.splice(+el.dataset.c, 1);
fragments(); refreshHud(); saveGame();
toggleMap(); toggleMap();
}));
}
}
// ────────────────────────────────────────────────────── the phile reader
// You spend the game collecting text files. There has to be somewhere to read them.
const readerEl = document.getElementById('reader');
let readerOpen = false, readerAt = 0;
function toggleReader() {
readerOpen = !readerOpen;
game.paused = readerOpen;
readerEl.style.display = readerOpen ? 'flex' : 'none';
if (readerOpen) { In.unlock(); drawReader(); }
}
function drawReader() {
if (!phileLog.length) {
readerEl.innerHTML = `