Download web/scene3d.js from visproj/lantern: direct link, hf CLI and curl.
- Browser
- Download file 19.1 kB
-
https://huggingface.co/spaces/visproj/lantern/resolve/main/web/scene3d.js
- Command line
-
hf download hf://spaces/visproj/lantern/web/scene3d.js
-
curl -L -o scene3d.js https://huggingface.co/spaces/visproj/lantern/resolve/main/web/scene3d.js
19.1 kB
| // 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(); | |