// explore-viewer.js — walk a pre-baked first-person dungeon. // // Loads an atlas manifest (room graph + tiles) and the baked frames from // /out/atlas//, and lets you move through it Eye-of-the-Beholder style: // you occupy a room and a cardinal facing; W/S step forward/back to the room in // that direction, A/D turn. Each frame is the pre-rendered pixel-art view for the // current (room, facing). Movement is just swapping the image — the dungeon was // "burned off" ahead of time by tools/bake_atlas.py. const params = new URLSearchParams(location.search); const SEED = params.get('seed') || '7'; const BASE = `/out/atlas/${SEED}`; const DIRS = ['N', 'E', 'S', 'W']; const VEC = { N: [0, -1], E: [1, 0], S: [0, 1], W: [-1, 0] }; const img = document.getElementById('frame'); const hud = document.getElementById('hud'); const miss = document.getElementById('miss'); const missDetail = document.getElementById('miss-detail'); const mini = document.getElementById('minimap'); const mctx = mini.getContext('2d'); let manifest, byId; const state = { room: null, facing: 'N' }; // One cache-bust token per page load: re-bakes show fresh art on reload, but // turning/stepping within a session still hits the browser cache (snappy). const BUST = `?t=${Date.now()}`; const frameURL = (id, dir) => `${BASE}/r${id}_${dir}.png${BUST}`; const room = () => byId.get(state.room); const opposite = (d) => DIRS[(DIRS.indexOf(d) + 2) % 4]; async function boot() { try { manifest = await (await fetch(`${BASE}/manifest.json`)).json(); } catch { hud.innerHTML = `no atlas for seed ${SEED} — bake one with tools/bake_atlas.py --seed ${SEED}`; return; } byId = new Map(manifest.rooms.map((r) => [r.id, r])); // ?room=&facing= jump straight to a room (demo/debug); else start at the entrance. const wantRoom = Number(params.get('room')); state.room = (params.has('room') && byId.has(wantRoom)) ? wantRoom : manifest.startRoom; const wantFacing = (params.get('facing') || '').toUpperCase(); state.facing = DIRS.includes(wantFacing) ? wantFacing : (firstOpenFacing(state.room) || 'N'); show(); preloadAround(); } function firstOpenFacing(id) { const nav = byId.get(id).nav; return DIRS.find((d) => nav[d] != null); } function show() { miss.classList.remove('show'); img.src = frameURL(state.room, state.facing); updateHud(); drawMinimap(); } function updateHud() { const r = room(); const exits = DIRS.filter((d) => r.nav[d] != null); const ahead = r.nav[state.facing] != null ? `→ room ${r.nav[state.facing]}` : 'wall'; hud.innerHTML = `seed ${manifest.seed} · room ${r.id}${r.theme ? ` · ${r.theme}` : ''} · ` + `facing ${state.facing} (${ahead})
` + `exits ${exits.join(' ') || '—'}`; } function turn(delta) { state.facing = DIRS[(DIRS.indexOf(state.facing) + delta + 4) % 4]; show(); } function step(forward) { const dir = forward ? state.facing : opposite(state.facing); const target = room().nav[dir]; if (target != null) { state.room = target; show(); preloadAround(); } else { img.animate( [{ filter: 'brightness(1)' }, { filter: 'brightness(0.55)' }, { filter: 'brightness(1)' }], { duration: 150 }, ); } } window.addEventListener('keydown', (e) => { const k = e.key.toLowerCase(); if (k === 'w' || k === 'arrowup') step(true); else if (k === 's' || k === 'arrowdown') step(false); else if (k === 'a' || k === 'arrowleft') turn(-1); else if (k === 'd' || k === 'arrowright') turn(1); else return; e.preventDefault(); }); // A frame that 404s just hasn't been baked yet — show a hint instead of a broken image. img.addEventListener('error', () => { if (!img.getAttribute('src')) return; miss.classList.add('show'); missDetail.textContent = `room ${state.room}, facing ${state.facing}`; }); // Warm the browser cache for instant turning/stepping. function preloadAround() { const seen = new Set(); const pre = (id) => DIRS.forEach((d) => { const u = frameURL(id, d); if (!seen.has(u)) { seen.add(u); new Image().src = u; } }); pre(state.room); const nav = room().nav; DIRS.forEach((d) => { if (nav[d] != null) pre(nav[d]); }); } function drawMinimap() { const { tiles, width, height } = manifest; const s = Math.max(2, Math.floor(170 / Math.max(width, height))); mini.width = width * s; mini.height = height * s; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { const t = tiles[y][x]; mctx.fillStyle = t === 0 ? '#15151c' : t === 2 ? '#caa24a' : '#34343f'; mctx.fillRect(x * s, y * s, s, s); } } for (const r of manifest.rooms) { // every room as a faint node mctx.fillStyle = 'rgba(122,160,220,0.45)'; mctx.fillRect(r.vantage[0] * s, r.vantage[1] * s, s, s); } const [vx, vy] = room().vantage; // the player + facing arrow const cx = vx * s + s / 2, cy = vy * s + s / 2; mctx.fillStyle = '#e8dcc8'; mctx.beginPath(); mctx.arc(cx, cy, Math.max(2, s * 0.7), 0, Math.PI * 2); mctx.fill(); const [dx, dy] = VEC[state.facing]; mctx.strokeStyle = '#d4a54a'; mctx.lineWidth = Math.max(1.5, s * 0.5); mctx.beginPath(); mctx.moveTo(cx, cy); mctx.lineTo(cx + dx * s * 2.4, cy + dy * s * 2.4); mctx.stroke(); } boot();