lantern / web /scene3d.js
visproj's picture
feat: show water falls and corner side-steps; River goal warns about unfilled water
d43e802 verified
Raw History Blame Contribute Delete
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();