// Lantern 3D view: the dungeon Beam is exploring, lit only by the lantern it carries. // Models: Kenney "Mini Dungeon" (CC0). Exposes window.Lantern3D for app.js. import * as THREE from "three"; import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js"; import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js"; const ASSET_DIR = "/static/assets/kenney-mini-dungeon/"; const MODEL_NAMES = ["floor", "floor-detail", "wall", "stairs", "key", "potion", "trap", "chest", "gate", "rocks", "character-human", "character-orc", "coin", "column"]; const WALKABLE = new Set(["floor", "doorway", "door_open", "door_closed", "stairs_down", "stairs_up", "item", "monster", "hero", "trap", "boulder", "feature", "tree", "cloud"]); const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; function webglAvailable() { try { const c = document.createElement("canvas"); return !!(window.WebGLRenderingContext && (c.getContext("webgl2") || c.getContext("webgl"))); } catch { return false; } } class DungeonScene { constructor(container) { this.container = container; this.renderer = new THREE.WebGLRenderer({ antialias: true }); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.shadowMap.enabled = true; this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; this.renderer.outputColorSpace = THREE.SRGBColorSpace; this.renderer.toneMapping = THREE.ACESFilmicToneMapping; container.appendChild(this.renderer.domElement); this.scene = new THREE.Scene(); this.scene.background = new THREE.Color("#0e1118"); this.scene.fog = new THREE.Fog("#0e1118", 10, 26); this.camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100); this.camOffset = new THREE.Vector3(0, 12.5, 9.5); this.camTarget = new THREE.Vector3(); // Moonlight: just enough to remember explored rooms. The lantern does the real lighting. this.scene.add(new THREE.HemisphereLight("#5b6f9e", "#0b0d12", 0.55)); this.lantern = new THREE.PointLight("#f4b860", 16, 10, 1.4); this.lantern.castShadow = true; this.lantern.shadow.mapSize.set(1024, 1024); this.lantern.shadow.bias = -0.004; this.scene.add(this.lantern); this.world = new THREE.Group(); this.scene.add(this.world); this.cells = new Map(); // "x,y" -> { kind, obj } this.actors = new THREE.Group(); this.scene.add(this.actors); this.models = {}; this.clock = new THREE.Clock(); this.lavaMats = []; this.heroTarget = null; new ResizeObserver(() => this.resize()).observe(container); this.resize(); } async load() { const loader = new GLTFLoader(); const loaded = await Promise.all(MODEL_NAMES.map((n) => loader.loadAsync(`${ASSET_DIR}${n}.glb`))); loaded.forEach((gltf, i) => { gltf.scene.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } }); this.models[MODEL_NAMES[i]] = gltf; }); this.makeMaterials(); this.makeHero(); this.renderer.setAnimationLoop(() => this.tick()); } makeMaterials() { this.mats = { // A faint glow of its own so water reads as water, not a hole, outside the lamplight. water: new THREE.MeshStandardMaterial({ color: "#2a6699", emissive: "#123a5c", emissiveIntensity: 0.9, roughness: 0.12, metalness: 0.25 }), lava: new THREE.MeshStandardMaterial({ color: "#5a1a05", emissive: "#ff6a1a", emissiveIntensity: 1.4, roughness: 0.6 }), ice: new THREE.MeshStandardMaterial({ color: "#bfe3f2", roughness: 0.1, metalness: 0.1 }), leaves: new THREE.MeshStandardMaterial({ color: "#3f7a4a", roughness: 0.9, flatShading: true }), trunk: new THREE.MeshStandardMaterial({ color: "#5a3d26", roughness: 1 }), cloud: new THREE.MeshStandardMaterial({ color: "#d9dde6", transparent: true, opacity: 0.55, roughness: 1 }), beacon: new THREE.MeshBasicMaterial({ color: "#f4b860", transparent: true, opacity: 0.55 }), }; this.lavaMats.push(this.mats.lava); // Geometry and label textures are created once and shared: cells are rebuilt and // monsters re-placed often, and new GPU buffers each time would leak. this.geo = { plane: new THREE.PlaneGeometry(1, 1), ring: new THREE.RingGeometry(0.38, 0.48, 40), trunk: new THREE.CylinderGeometry(0.07, 0.09, 0.4, 6), crown: new THREE.ConeGeometry(0.36, 0.9, 7), puff: new THREE.SphereGeometry(1, 10, 8), }; this.labelMats = new Map(); // monster name -> SpriteMaterial } makeHero() { const gltf = this.models["character-human"]; this.hero = SkeletonUtils.clone(gltf.scene); this.hero.scale.setScalar(1.6); this.mixer = new THREE.AnimationMixer(this.hero); const clip = (name) => THREE.AnimationClip.findByName(gltf.animations, name); this.anims = { idle: this.mixer.clipAction(clip("idle")), walk: this.mixer.clipAction(clip("walk")) }; this.anims.idle.play(); this.current = "idle"; this.hero.visible = false; this.scene.add(this.hero); } clone(name, scale = 1) { const obj = SkeletonUtils.clone(this.models[name].scene); obj.scale.setScalar(scale); return obj; } // ── building cells ────────────────────────────── buildCell(kind, x, y, neighbors, label) { const g = new THREE.Group(); g.position.set(x, 0, y); const plane = (mat, h = -0.02) => { const m = new THREE.Mesh(this.geo.plane, mat); m.rotation.x = -Math.PI / 2; m.position.y = h; m.receiveShadow = true; return m; }; if (kind === "wall") { g.add(this.clone("wall")); return g; } if (kind === "corridor") { g.add(this.clone("floor-detail")); return g; } if (kind === "water") { g.add(plane(this.mats.water, -0.08)); return g; } if (kind === "lava") { g.add(plane(this.mats.lava, -0.06)); return g; } if (kind === "ice") { g.add(plane(this.mats.ice)); return g; } if (!WALKABLE.has(kind)) return null; g.add(this.clone("floor")); if (kind === "door_closed" || kind === "door_open") { const gate = this.clone("gate"); // Doors sit in a wall line; face the gate across the gap. const verticalWall = neighbors.n === "wall" || neighbors.s === "wall"; gate.rotation.y = verticalWall ? Math.PI / 2 : 0; if (kind === "door_open") { gate.rotation.y += Math.PI / 2; gate.position.x += verticalWall ? 0 : -0.35; } g.add(gate); } else if (kind === "stairs_down" || kind === "stairs_up") { g.add(this.clone("stairs")); if (kind === "stairs_down") { const ring = new THREE.Mesh(this.geo.ring, this.mats.beacon); ring.rotation.x = -Math.PI / 2; ring.position.y = 0.02; ring.userData.beacon = true; g.add(ring); } } else if (kind === "trap") { g.add(this.clone("trap")); } else if (kind === "boulder") { g.add(this.clone("rocks", 1.5)); } else if (kind === "feature") { g.add(this.clone("column")); } else if (kind === "tree") { const trunk = new THREE.Mesh(this.geo.trunk, this.mats.trunk); trunk.position.y = 0.2; const crown = new THREE.Mesh(this.geo.crown, this.mats.leaves); crown.position.y = 0.8; [trunk, crown].forEach((m) => { m.castShadow = true; g.add(m); }); } else if (kind === "cloud") { [[0, 0.5, 0, 0.32], [0.2, 0.6, 0.1, 0.24], [-0.2, 0.55, -0.1, 0.26]].forEach(([px, py, pz, r]) => { const s = new THREE.Mesh(this.geo.puff, this.mats.cloud); s.scale.setScalar(r); s.position.set(px, py, pz); g.add(s); }); } else if (kind === "item") { const name = (label || "").toLowerCase(); const model = name.includes("key") ? "key" : name.includes("potion") ? "potion" : name.includes("ring") || name.includes("coin") || name.includes("gold") ? "coin" : "chest"; const item = this.clone(model, model === "chest" ? 0.7 : 1.4); item.userData.bob = true; g.add(item); } return g; } makeLabel(text) { let mat = this.labelMats.get(text); if (!mat) { const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); const font = "600 44px Spectral, Georgia, serif"; ctx.font = font; canvas.width = Math.ceil(ctx.measureText(text).width) + 36; canvas.height = 64; ctx.font = font; ctx.fillStyle = "rgba(14,17,24,0.78)"; ctx.beginPath(); ctx.roundRect(0, 0, canvas.width, canvas.height, 14); ctx.fill(); ctx.fillStyle = "#e9e3d3"; ctx.textBaseline = "middle"; ctx.fillText(text, 18, 34); const tex = new THREE.CanvasTexture(canvas); tex.colorSpace = THREE.SRGBColorSpace; mat = new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true, toneMapped: false, fog: false }); mat.userData.aspect = canvas.width / canvas.height; this.labelMats.set(text, mat); } const sprite = new THREE.Sprite(mat); // sprites are cheap; the texture is shared sprite.scale.set(mat.userData.aspect * 0.32, 0.32, 1); sprite.renderOrder = 10; return sprite; } /** Show a speech bubble over Beam (its reply to the watcher) for `ms` milliseconds. */ say(text, ms = 6000) { this.clearSpeech(); const words = String(text).split(/\s+/); const lines = [""]; for (const w of words) { if ((lines[lines.length - 1] + " " + w).trim().length > 30) lines.push(w); else lines[lines.length - 1] = (lines[lines.length - 1] + " " + w).trim(); } if (lines.length > 4) { lines.length = 4; lines[3] = lines[3].replace(/\s*\S*$/, "") + "…"; } const font = "600 34px Spectral, Georgia, serif", lineH = 42, pad = 22, tail = 22; const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); ctx.font = font; const w = Math.ceil(Math.max(...lines.map((l) => ctx.measureText(l).width))) + pad * 2; const h = lines.length * lineH + pad * 2; canvas.width = w; canvas.height = h + tail; ctx.fillStyle = "rgba(233, 227, 211, 0.96)"; ctx.beginPath(); ctx.roundRect(0, 0, w, h, 18); ctx.fill(); ctx.beginPath(); ctx.moveTo(w / 2 - 16, h - 1); ctx.lineTo(w / 2, h + tail); ctx.lineTo(w / 2 + 16, h - 1); ctx.fill(); ctx.font = font; ctx.fillStyle = "#1a1f2b"; ctx.textBaseline = "top"; lines.forEach((l, i) => ctx.fillText(l, pad, pad + i * lineH)); const tex = new THREE.CanvasTexture(canvas); tex.colorSpace = THREE.SRGBColorSpace; // toneMapped off: the scene's filmic grading would grey the bubble out. const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, depthTest: false, transparent: true, toneMapped: false, fog: false })); const height = 0.38 * (canvas.height / lineH); sprite.scale.set(height * canvas.width / canvas.height, height, 1); sprite.renderOrder = 20; this.scene.add(sprite); this.speech = { sprite, until: performance.now() + ms, height }; } /** Remove the speech bubble and free its texture (each bubble's text is unique). */ clearSpeech() { if (!this.speech) return; this.scene.remove(this.speech.sprite); this.speech.sprite.material.map.dispose(); this.speech.sprite.material.dispose(); this.speech = null; } /** Glide the camera in close on Beam for a key moment (skipped for reduced motion). */ spotlight(ms = 2500) { if (reduceMotion) return; this.spotlightUntil = performance.now() + ms; } /** Forget everything; called when a different episode is shown. */ reset() { for (const cell of this.cells.values()) if (cell.obj) this.world.remove(cell.obj); this.cells.clear(); this.remembered = new Set(); this.actors.clear(); this.hero.visible = false; this.heroTarget = null; this.route = []; this.clearSpeech(); } // ── per-frame update from the game ───────────── update(frame, path) { if (!frame || !frame.kinds || !frame.kinds.length) return; const [top, left] = frame.origin; // NetHack can blank out dark floor once Beam walks away. Keep drawing any cell Beam // has stood on or seen as walkable, so it never appears to cross a void. this.remembered = this.remembered || new Set(); this.remembered.add(`${frame.status.x},${frame.status.y}`); (path || []).forEach(([x, y, mark]) => { if (!mark) this.remembered.add(`${x},${y}`); }); const kindAt = (r, c) => (frame.kinds[r] && frame.kinds[r][c]) || "unknown"; const labels = new Map(frame.labels.map(([r, c, name]) => [`${r},${c}`, name])); const seen = new Set(); frame.kinds.forEach((row, r) => row.forEach((kind, c) => { const x = c + left, y = r + top, key = `${x},${y}`; // Actors are drawn separately; the tile under them is floor. let tile = kind === "hero" || kind === "monster" ? "floor" : kind; if (WALKABLE.has(tile) || tile === "corridor") this.remembered.add(key); else if (tile === "unknown" && this.remembered.has(key)) tile = "floor"; const label = kind === "item" ? labels.get(`${r},${c}`) : ""; const sig = `${tile}|${label || ""}`; seen.add(key); const prev = this.cells.get(key); if (prev && prev.sig === sig) return; if (prev && prev.obj) this.world.remove(prev.obj); const neighbors = { n: kindAt(r - 1, c), s: kindAt(r + 1, c), e: kindAt(r, c + 1), w: kindAt(r, c - 1) }; const obj = this.buildCell(tile, x, y, neighbors, label); if (obj) this.world.add(obj); this.cells.set(key, { sig, obj }); })); for (const [key, cell] of this.cells) { if (!seen.has(key)) { if (cell.obj) this.world.remove(cell.obj); this.cells.delete(key); } } // Monsters: few per level, so rebuild each frame. this.actors.clear(); frame.kinds.forEach((row, r) => row.forEach((kind, c) => { if (kind !== "monster") return; const m = this.clone("character-orc", 1.5); m.position.set(c + left, 0, r + top); m.lookAt(frame.status.x, 0, frame.status.y); this.actors.add(m); const name = labels.get(`${r},${c}`); if (name) { const s = this.makeLabel(name); s.position.set(c + left, 1.05, r + top); this.actors.add(s); } })); // Frame the camera on the explored area; large maps lean toward following Beam. const w = frame.kinds[0].length, h = frame.kinds.length; this.levelCenter = new THREE.Vector3(left + (w - 1) / 2, 0, top + (h - 1) / 2); this.follow = w > 26 || h > 14 ? 0.8 : 0.3; this.zoom = THREE.MathUtils.clamp(Math.max(w / 18, h / 10), 0.85, 1.35); // Walk the hero through every intermediate step, never in a straight line across // walls; snap when jumping elsewhere on the timeline. const target = new THREE.Vector3(frame.status.x, 0, frame.status.y); // A step marked "water" is a fall: the hero sinks to its chest there before climbing out. const route = (path || []).map(([x, y, fell]) => new THREE.Vector3(x, fell === "water" ? -0.45 : 0, y)); const first = route[0] || target; if (!this.hero.visible || reduceMotion || first.distanceTo(this.hero.position) > 1.5) { this.hero.position.copy(target); this.camTarget.copy(target); this.route = []; } else { this.route = route.length ? route : [target]; } this.hero.visible = true; this.heroTarget = target; } play(name) { if (this.current === name) return; this.anims[this.current].fadeOut(0.15); this.anims[name].reset().fadeIn(0.15).play(); this.current = name; } tick() { const dt = Math.min(this.clock.getDelta(), 0.05); const t = this.clock.elapsedTime; if (this.heroTarget) { const next = (this.route && this.route[0]) || this.heroTarget; const toNext = next.clone().sub(this.hero.position); const dist = toNext.length(); if (dist > 0.02) { this.hero.position.addScaledVector(toNext.normalize(), Math.min(dist, dt * 4.5)); this.hero.rotation.y = Math.atan2(toNext.x, toNext.z); this.play("walk"); } else { this.hero.position.copy(next); if (this.route && this.route.length) this.route.shift(); if (!this.route || !this.route.length) this.play("idle"); } } this.mixer && this.mixer.update(dt); if (this.speech) { // follow Beam, fade out over the last half second const left = this.speech.until - performance.now(); if (left <= 0) this.clearSpeech(); else { this.speech.sprite.position.set(this.hero.position.x, 1.15 + this.speech.height / 2, this.hero.position.z); this.speech.sprite.material.opacity = Math.min(1, left / 500); } } const flicker = reduceMotion ? 0 : Math.sin(t * 11) * 0.5 + Math.sin(t * 17.3) * 0.35; this.lantern.intensity = 16 + flicker; this.lantern.position.set(this.hero.position.x + 0.25, 1.9, this.hero.position.z + 0.25); // Spotlight: ease toward Beam and in close for a key moment, then back out. const wantBoost = performance.now() < (this.spotlightUntil || 0) ? 1 : 0; this.boost = (this.boost || 0) + (wantBoost - (this.boost || 0)) * Math.min(1, dt * 2.5); const follow = (this.follow ?? 1) + (1 - (this.follow ?? 1)) * this.boost; const focus = this.levelCenter ? this.levelCenter.clone().lerp(this.hero.position, follow) : this.hero.position; this.camTarget.lerp(focus, reduceMotion ? 1 : Math.min(1, dt * 3)); const zoom = (this.zoom || 1) * (1 - 0.55 * this.boost); this.camera.position.copy(this.camTarget).addScaledVector(this.camOffset, zoom); this.camera.lookAt(this.camTarget); if (!reduceMotion) { this.lavaMats.forEach((m) => { m.emissiveIntensity = 1.25 + Math.sin(t * 2.2) * 0.25; }); this.world.traverse((o) => { if (o.userData.beacon) o.material.opacity = 0.35 + Math.sin(t * 3) * 0.2; if (o.userData.bob) o.position.y = 0.06 + Math.sin(t * 2.5 + o.parent.position.x) * 0.04; }); } this.renderer.render(this.scene, this.camera); } resize() { const w = this.container.clientWidth, h = this.container.clientHeight; if (!w || !h) return; this.renderer.setSize(w, h, false); this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); } } async function boot() { const container = document.getElementById("view3d"); if (!container || !webglAvailable()) return; try { const view = new DungeonScene(container); await view.load(); window.Lantern3D = view; document.body.classList.add("has-3d"); window.dispatchEvent(new Event("lantern3d-ready")); } catch (err) { console.warn("3D view unavailable, using the text map.", err); } } boot();