Files
wownerogue/html/js/render/threeRenderer.js
jwinterm ed24d43b56 chore: prepare the repository for public release
Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.

Private material moved out of the public tree (docs/private/, git-ignored):
  - the wallet keyfile recovery runbook and its helper scripts, which together
    published the mainnet wallet file path, the service account, backup snapshot
    filenames, and the wallet RPC listener state. The helper duplicated the same
    constants, so relocating the document alone would not have removed them.
  - the stagenet parallel-candidate runbook and its fixtures.
  - the dated production-state record, the internal remediation plan, and the
    TODO list. None are reference documentation for an outside reader.

Redacted or genericised:
  - operator home-directory paths in shipped scripts and units. The asset build
    script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
  - private LAN addresses in tests, env examples and deploy docs, replaced with
    RFC 5737 documentation addresses.
  - the default alert sender and the notification hub example.
  - the Monero wallet unit now matches the Wownero one: a dedicated service
    account and a config file, instead of the operator's login account with the
    wallet password in argv where any local user can read it from /proc.

Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.

Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.

Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.

Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.

127 suites / 1015 tests pass.
2026-07-27 06:40:38 -04:00

955 lines
44 KiB
JavaScript

// ThreeRenderer: a lit, textured 3D projection of the shared Scene. It uses GLB avatars when
// available and falls back to low-poly pieces so the mode stays usable without them.
//
// The level is drawn with three InstancedMeshes (walls / ground / props) so a 70x35 dungeon costs
// three draw calls instead of ~2500 individual meshes, and per-instance colour carries the same
// fog-of-war brightness the 2D modes use. Textures are generated on a canvas at startup: no new
// asset downloads, nothing for the CSP to block, and the walls read as stone instead of flat boxes.
(function (root) {
'use strict';
function colorNum(hex) { return parseInt(String(hex || '#9aa4b2').replace('#', ''), 16) || 0x9aa4b2; }
function tintForVisual(visual, fallback) {
if (root.RK && RK.avatarVisuals && RK.avatarVisuals.tintColorFor) {
return RK.avatarVisuals.tintColorFor(visual && visual.appearance, fallback);
}
return fallback || null;
}
// Deterministic PRNG: generated textures are byte-identical every session, so the dungeon
// does not subtly re-skin itself on reload.
function lcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
function makeCanvas(size, paint) {
var cv = root.document.createElement('canvas');
cv.width = size; cv.height = size;
paint(cv.getContext('2d'), size);
return cv;
}
// Coursed stone blocks: mortar gaps, per-block value variation, a lit top edge and a shaded
// bottom edge. The bevel is baked in so the walls have relief even at grazing light angles.
function stoneCanvas(size) {
return makeCanvas(size, function (c, s) {
var rnd = lcg(0x5713);
c.fillStyle = '#241f1a'; c.fillRect(0, 0, s, s);
var rows = 4, h = s / rows, bw = s / 2.5;
for (var r = 0; r < rows; r++) {
var offset = (r % 2) * (bw / 2);
for (var bx = -1; bx < 4; bx++) {
var x = bx * bw + offset, y = r * h;
var v = 116 + Math.floor(rnd() * 40);
c.fillStyle = 'rgb(' + v + ',' + (v - 7) + ',' + (v - 20) + ')';
c.fillRect(x + 2, y + 2, bw - 4, h - 4);
c.fillStyle = 'rgba(255,246,225,0.11)'; c.fillRect(x + 2, y + 2, bw - 4, 2);
c.fillStyle = 'rgba(0,0,0,0.26)'; c.fillRect(x + 2, y + h - 4, bw - 4, 2);
}
}
for (var i = 0; i < s * 10; i++) {
c.fillStyle = 'rgba(0,0,0,' + (rnd() * 0.18).toFixed(3) + ')';
c.fillRect(Math.floor(rnd() * s), Math.floor(rnd() * s), 1, 1);
}
});
}
// Irregular flagstones: a broken grout grid plus grain, so a large floor never shows an
// obvious repeat.
function groundCanvas(size) {
return makeCanvas(size, function (c, s) {
var rnd = lcg(0x2f19);
c.fillStyle = '#1d1a16'; c.fillRect(0, 0, s, s);
var n = 4, cell = s / n;
for (var gy = 0; gy < n; gy++) {
for (var gx = 0; gx < n; gx++) {
var pad = 1.5 + rnd() * 1.5;
var v = 96 + Math.floor(rnd() * 34);
c.fillStyle = 'rgb(' + v + ',' + (v - 4) + ',' + (v - 14) + ')';
c.fillRect(gx * cell + pad, gy * cell + pad, cell - pad * 2, cell - pad * 2);
c.fillStyle = 'rgba(255,244,220,0.06)';
c.fillRect(gx * cell + pad, gy * cell + pad, cell - pad * 2, 1.5);
}
}
for (var i = 0; i < s * 14; i++) {
c.fillStyle = 'rgba(0,0,0,' + (rnd() * 0.2).toFixed(3) + ')';
c.fillRect(Math.floor(rnd() * s), Math.floor(rnd() * s), 1, 1);
}
});
}
// Vertical fade for the objective beacons: bright at the floor, gone by the top. Without it the
// beam cylinder renders as a flat uniform column that looks like a solid prop.
function beamCanvas(h) {
var cv = root.document.createElement('canvas');
cv.width = 4; cv.height = h;
var c = cv.getContext('2d');
var g = c.createLinearGradient(0, h, 0, 0);
g.addColorStop(0, 'rgba(255,255,255,0.85)');
g.addColorStop(0.35, 'rgba(255,255,255,0.30)');
g.addColorStop(1, 'rgba(255,255,255,0)');
c.fillStyle = g; c.fillRect(0, 0, 4, h);
return cv;
}
// Soft round sprite for the dust motes drifting in the torchlight.
function moteCanvas(size) {
return makeCanvas(size, function (c, s) {
var g = c.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
g.addColorStop(0, 'rgba(255,236,196,1)');
g.addColorStop(0.45, 'rgba(255,206,140,0.5)');
g.addColorStop(1, 'rgba(255,190,120,0)');
c.fillStyle = g; c.fillRect(0, 0, s, s);
});
}
// Wall-family kinds occupy the full cell height; ground kinds are a thin slab; everything else
// is a prop box sized per kind. `emissive` marks hazards/fire that glow on their own.
var WALLISH = { wall: 1, window: 1, door: 1, archway: 1, torch: 1 };
var GROUND = { floor: 1, floor2: 1, rug: 1, dirt: 1, entrance: 1, exit: 1, treasure: 1 };
var PROP_HEIGHT = {
bar: 0.52, table: 0.40, chair: 0.30, keg: 0.50, barrel: 0.50, shelf: 0.92,
crate: 0.44, chest: 0.38, hearth: 0.30, column: 1.30,
lava: 0.05, poison: 0.05, spikes: 0.09
};
var HAZARD_GLOW = { lava: 0.85, poison: 0.5, spikes: 0.12 };
// Ground tints. The scene legend colours are tuned for flat 2D fills and read almost black in a
// lit 3D scene, so wall/floor use material-appropriate values; everything else falls back to
// the legend.
var KIND_TINT = {
wall: '#a49a8b', window: '#7d94a8', door: '#9c7a4a', archway: '#a49a8b',
torch: '#a49a8b',
floor: '#7a6a53', floor2: '#6a5d4b', rug: '#8a4a45', dirt: '#645540',
entrance: '#4d7f57', exit: '#8a743c', treasure: '#7c7062',
lava: '#ff6a2a', poison: '#4fd07a', spikes: '#9aa3ad'
};
function ThreeRenderer(host, opts) {
opts = opts || {};
if (!root.RK || !RK.THREE || !RK.THREE.THREE) throw new Error('THREE not loaded');
this.name = '3d';
this.host = host;
this.THREE = RK.THREE.THREE;
this.GLTFLoader = RK.THREE.GLTFLoader;
this.SkeletonUtils = RK.THREE.SkeletonUtils;
this.models = {};
this.mixers = [];
this.entities = {};
this.last = {};
this.zoom = 1;
this.panX = 0;
this.panY = 0;
this.buckets = {};
this.torchCells = [];
this._destroyed = false;
this._init();
}
ThreeRenderer.prototype._init = function () {
var T = this.THREE;
this.scene = new T.Scene();
this.scene.background = new T.Color(0x000000);
// Fog-of-war parity with the 2D modes: the camera follows the player, so distance fog fades
// explored tiles to black away from you, and _buildTiles skips unexplored ('dark') tiles so
// they never render. Together: unexplored = hidden, explored fades with distance, no map leak.
// The fog band is measured from the CAMERA, which sits ~17 units back along the isometric
// offset, not from the player. Tuning it as if it started at the player puts the player a
// quarter of the way into fog and crushes the scene to near-black.
this.scene.fog = new T.Fog(0x05060a, 21, 46);
this.camera = new T.OrthographicCamera(-10, 10, 10, -10, 0.1, 120);
this.camera.position.set(9, 11, 9);
this.camera.lookAt(0, 0, 0);
this.renderer = new T.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
this.renderer.domElement.className = 'rk-canvas';
this.canvas = this.renderer.domElement;
this.renderer.setPixelRatio(Math.min(2, root.devicePixelRatio || 1));
if (T.SRGBColorSpace) this.renderer.outputColorSpace = T.SRGBColorSpace;
if (T.ACESFilmicToneMapping != null) {
this.renderer.toneMapping = T.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.22;
}
this.renderer.shadowMap.enabled = true;
if (T.PCFSoftShadowMap != null) this.renderer.shadowMap.type = T.PCFSoftShadowMap;
this.host.appendChild(this.renderer.domElement);
this._buildMaterials();
// A dim cool ambient keeps unlit corners from going pure black; the warm key light and the
// player's torch do the actual shaping.
var hemi = new T.HemisphereLight(0x9fb4d8, 0x161a24, 0.5);
this.scene.add(hemi);
this.keyLight = new T.DirectionalLight(0xffd9a8, 2.3);
this.keyLight.position.set(10, 8.5, 4); // low sun: long, readable wall shadows
this.keyLight.castShadow = true;
this.keyLight.shadow.mapSize.set(1024, 1024);
this.keyLight.shadow.camera.near = 1;
this.keyLight.shadow.camera.far = 46;
this.keyLight.shadow.camera.left = -13;
this.keyLight.shadow.camera.right = 13;
this.keyLight.shadow.camera.top = 13;
this.keyLight.shadow.camera.bottom = -13;
this.keyLight.shadow.bias = -0.0015;
this.keyLight.shadow.normalBias = 0.04;
this.scene.add(this.keyLight);
this.scene.add(this.keyLight.target);
// A player-carried torch adds depth and keeps the followed avatar readable without washing
// out the dungeon's black fog-of-war background.
this.playerLight = new T.PointLight(0xffb35c, 4.4, 14, 2);
this.playerLight.position.set(0, 2.6, 0);
this.scene.add(this.playerLight);
// A small pool of lights reassigned to whichever torches are nearest the player each frame:
// a level can hold dozens of torches, but only a handful are ever on screen.
this.torchLights = [];
for (var i = 0; i < 6; i++) {
var pl = new T.PointLight(0xff9a3c, 0, 11, 2);
pl.visible = false;
this.scene.add(pl);
this.torchLights.push(pl);
}
this.world = new T.Group();
this.scene.add(this.world);
this.tileLayer = new T.Group();
this.fixtureLayer = new T.Group();
this.entityLayer = new T.Group();
this.world.add(this.tileLayer);
this.world.add(this.fixtureLayer);
this.world.add(this.entityLayer);
this._buildMotes();
this.clock = new T.Clock();
var self = this;
this._raf = requestAnimationFrame(function tick() {
if (self._destroyed || !self.renderer) { self._raf = null; return; }
if (self._animate() === false) { self._raf = null; return; }
self._raf = requestAnimationFrame(tick);
});
};
ThreeRenderer.prototype._buildMaterials = function () {
var T = this.THREE;
function tex(canvas, repeat) {
var t = new T.CanvasTexture(canvas);
t.wrapS = t.wrapT = T.RepeatWrapping;
t.repeat.set(repeat, repeat);
t.anisotropy = 4;
return t;
}
var stone = stoneCanvas(128), ground = groundCanvas(128);
this._textures = [];
var stoneMap = tex(stone, 1), groundMap = tex(ground, 1);
var stoneBump = tex(stone, 1), groundBump = tex(ground, 1);
if (T.SRGBColorSpace) { stoneMap.colorSpace = T.SRGBColorSpace; groundMap.colorSpace = T.SRGBColorSpace; }
this._textures.push(stoneMap, groundMap, stoneBump, groundBump);
this.wallMat = new T.MeshStandardMaterial({
map: stoneMap, bumpMap: stoneBump, bumpScale: 0.55, roughness: 0.94, metalness: 0.02
});
this.groundMat = new T.MeshStandardMaterial({
map: groundMap, bumpMap: groundBump, bumpScale: 0.35, roughness: 0.96, metalness: 0.0
});
this.propMat = new T.MeshStandardMaterial({ roughness: 0.8, metalness: 0.05 });
// Hazards/fire glow on their own so they stay legible in an unlit corridor.
this.glowMat = new T.MeshStandardMaterial({
roughness: 0.55, metalness: 0.0, emissive: new T.Color(0xffffff), emissiveIntensity: 0.9
});
};
// Dust motes drifting through the torchlight. Purely atmospheric, one draw call, and they are
// re-centred on the player so the same 160 particles cover the whole level.
ThreeRenderer.prototype._buildMotes = function () {
var T = this.THREE, n = 160;
var pos = new Float32Array(n * 3);
this._moteSeed = new Float32Array(n);
var rnd = lcg(0x9e37);
for (var i = 0; i < n; i++) {
pos[i * 3] = (rnd() - 0.5) * 26;
pos[i * 3 + 1] = rnd() * 5.5;
pos[i * 3 + 2] = (rnd() - 0.5) * 26;
this._moteSeed[i] = rnd() * 6.28;
}
var geom = new T.BufferGeometry();
geom.setAttribute('position', new T.BufferAttribute(pos, 3));
var map = new T.CanvasTexture(moteCanvas(32));
this._textures.push(map);
this._moteMat = new T.PointsMaterial({
size: 0.11, map: map, transparent: true, depthWrite: false,
blending: T.AdditiveBlending, opacity: 0.55, sizeAttenuation: true
});
this.motes = new T.Points(geom, this._moteMat);
this.motes.frustumCulled = false;
this.scene.add(this.motes);
};
// Fill the host and frame a fixed "screenful" around the player. The camera follows the player
// (see _animate) instead of framing the whole level, so 3D behaves like the other modes. Only
// does work when the host size actually changed, so it is cheap to call every frame.
ThreeRenderer.prototype._fitToHost = function () {
var host = this.host;
var w = (host && (host.clientWidth || host.offsetWidth)) || 640;
var h = (host && (host.clientHeight || host.offsetHeight)) || 400;
if (w === this._cw && h === this._ch) return;
this._cw = w; this._ch = h;
this.renderer.setSize(w, h, true); // updateStyle:true so the canvas CSS-fills the host
var aspect = w / h, span = 6.6 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
this.camera.left = -span * aspect;
this.camera.right = span * aspect;
this.camera.top = span;
this.camera.bottom = -span;
this.camera.updateProjectionMatrix();
};
ThreeRenderer.prototype.setZoom = function (zoom) {
var next = Math.max(0.55, Math.min(3.2, Number(zoom) || 1));
if (Math.abs(next - this.zoom) < 0.001) return;
this.zoom = next;
this._cw = null; // force projection update even when the host dimensions did not change
this._fitToHost();
};
// `attachCamera` reports drag pan in viewport pixels. 3D keeps that contract and converts the
// offset to ground-plane world units in _animate(), where the followed player's current smoothed
// position is available. The bound of one viewport keeps a stray gesture from losing the dungeon.
ThreeRenderer.prototype.setPan = function (x, y) {
var w = (this.host && (this.host.clientWidth || this.host.offsetWidth)) || 640;
var h = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
this.panX = Math.max(-w, Math.min(w, Number(x) || 0));
this.panY = Math.max(-h, Math.min(h, Number(y) || 0));
};
ThreeRenderer.prototype._mat = function (hex) {
var T = this.THREE;
return new T.MeshStandardMaterial({ color: colorNum(hex), roughness: 0.82, metalness: 0.02 });
};
ThreeRenderer.prototype._disposeObject = function (object) {
if (!object || !object.traverse) return;
var keep = [this.wallMat, this.groundMat, this.propMat, this.glowMat];
object.traverse(function (obj) {
if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose();
if (!obj.material) return;
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
materials.forEach(function (mat) {
if (mat && mat.dispose && keep.indexOf(mat) === -1) mat.dispose();
});
});
};
// One InstancedMesh per bucket, grown geometrically. Reusing the mesh across rebuilds keeps a
// fog-of-war update from re-allocating the whole level every time the player steps.
ThreeRenderer.prototype._bucket = function (name, geom, mat, needed) {
var T = this.THREE;
var b = this.buckets[name];
if (!b || b.capacity < needed) {
if (b) { this.tileLayer.remove(b.mesh); b.mesh.dispose(); }
var capacity = Math.max(64, Math.ceil(needed * 1.35));
var mesh = new T.InstancedMesh(geom, mat, capacity);
mesh.instanceMatrix.setUsage(T.DynamicDrawUsage);
mesh.castShadow = name !== 'ground';
mesh.receiveShadow = true;
mesh.frustumCulled = false;
this.tileLayer.add(mesh);
b = this.buckets[name] = { mesh: mesh, capacity: capacity, geom: geom };
}
return b;
};
ThreeRenderer.prototype._buildTiles = function (scene) {
var T = this.THREE;
if (!this._geom) {
this._geom = {
wall: new T.BoxGeometry(1, 1, 1),
ground: new T.BoxGeometry(1, 0.12, 1),
prop: new T.BoxGeometry(1, 1, 1)
};
}
var legend = scene.legend || {};
var light = scene.lightGrid || null;
var cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
var walls = [], grounds = [], props = [];
this.torchCells = [];
var y, x, kind;
for (y = 0; y < scene.rows; y++) {
for (x = 0; x < scene.cols; x++) {
kind = scene.grid[y] && scene.grid[y][x];
if (!kind || kind === 'dark') continue; // unexplored: never rendered (fog-of-war)
var def = legend[kind] || {};
var lb = (light && light[y] && light[y][x] != null) ? light[y][x] : 1;
// In the 2D modes lightGrid IS the lighting; here real point lights do the falloff.
// Applying the grid at full strength to the albedo as well would double-darken every
// surface into near-black, so it is compressed into a gentle range and the lamps
// shape the scene.
var b = 0.38 + 0.62 * Math.max(0, Math.min(1, lb));
var tint = KIND_TINT[kind] || def.color || '#6d6558';
var cell = { x: x - cx, z: y - cz, gx: x, gy: y, b: b, tint: tint, kind: kind };
if (WALLISH[kind]) walls.push(cell);
else if (GROUND[kind]) grounds.push(cell);
else props.push(cell);
// Wall-mounted torches: a brazier fixture + a light source candidate.
if (def.fx === 'fire') this.torchCells.push(cell);
if (def.hazard) cell.hazard = def.hazard;
}
}
this._wallCells = walls;
this._writeGround(grounds);
this._writeProps(props);
this._writeWalls();
this._syncFixtures();
};
ThreeRenderer.prototype._writeGround = function (cells) {
var T = this.THREE;
var b = this._bucket('ground', this._geom.ground, this.groundMat, cells.length);
var m = new T.Matrix4(), c = new T.Color();
for (var i = 0; i < cells.length; i++) {
var cell = cells[i];
m.makeTranslation(cell.x, 0, cell.z);
b.mesh.setMatrixAt(i, m);
c.set(colorNum(cell.tint)).multiplyScalar(cell.b);
b.mesh.setColorAt(i, c);
}
b.mesh.count = cells.length;
b.mesh.instanceMatrix.needsUpdate = true;
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
ThreeRenderer.prototype._writeProps = function (cells) {
var T = this.THREE;
var b = this._bucket('prop', this._geom.prop, this.propMat, cells.length);
var m = new T.Matrix4(), q = new T.Quaternion(), s = new T.Vector3(), p = new T.Vector3(), c = new T.Color();
for (var i = 0; i < cells.length; i++) {
var cell = cells[i];
var h = PROP_HEIGHT[cell.kind] != null ? PROP_HEIGHT[cell.kind] : 0.3;
s.set(0.88, h, 0.88);
p.set(cell.x, h / 2, cell.z);
m.compose(p, q, s);
b.mesh.setMatrixAt(i, m);
// Hazards keep near-full brightness: a lava pool you can barely see is a trap, not a
// difficulty curve.
var hb = cell.hazard ? Math.max(0.7, cell.b) : cell.b;
c.set(colorNum(cell.tint)).multiplyScalar(hb);
b.mesh.setColorAt(i, c);
}
b.mesh.count = cells.length;
b.mesh.instanceMatrix.needsUpdate = true;
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
// Walls are rewritten whenever the player's cell changes so the cutaway follows them: walls
// sitting between the isometric camera (+x/+z) and the player are squashed and darkened, the 3D
// equivalent of the iso renderer's fade. Without it the near wall of every corridor hides you.
ThreeRenderer.prototype._writeWalls = function () {
var cells = this._wallCells;
if (!cells) return;
var T = this.THREE;
var b = this._bucket('wall', this._geom.wall, this.wallMat, cells.length);
var m = new T.Matrix4(), q = new T.Quaternion(), s = new T.Vector3(), p = new T.Vector3(), c = new T.Color();
var px = this._plx, py = this._ply;
for (var i = 0; i < cells.length; i++) {
var cell = cells[i];
var h = 1.45, dim = 1;
if (px != null && cell.gx >= px && cell.gy >= py) {
var d = (cell.gx - px) + (cell.gy - py);
if (d > 0 && d <= 3) { h = 0.40; dim = 0.62; }
}
s.set(1, h, 1);
p.set(cell.x, h / 2, cell.z);
m.compose(p, q, s);
b.mesh.setMatrixAt(i, m);
c.set(colorNum(cell.tint)).multiplyScalar(cell.b * dim);
b.mesh.setColorAt(i, c);
}
b.mesh.count = cells.length;
b.mesh.instanceMatrix.needsUpdate = true;
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
// Braziers on the torch walls. Only newly appeared cells get geometry: the tile buckets are
// rewritten on every move (that is how remembered tiles keep dimming), and building a fresh mesh
// per torch per step would churn the GPU for no visual gain.
ThreeRenderer.prototype._syncFixtures = function () {
var T = this.THREE;
this._flames = this._flames || {};
var want = {}, i, cell, k;
for (i = 0; i < this.torchCells.length; i++) {
cell = this.torchCells[i];
k = cell.gx + ',' + cell.gy;
want[k] = true;
if (this._flames[k]) { cell.flame = this._flames[k].flame; continue; }
var bowl = new T.Mesh(
new T.CylinderGeometry(0.17, 0.10, 0.16, 8),
new T.MeshStandardMaterial({ color: 0x2a2320, roughness: 0.9 })
);
bowl.position.set(cell.x, 1.30, cell.z);
this.fixtureLayer.add(bowl);
var flame = new T.Mesh(
new T.ConeGeometry(0.13, 0.34, 7),
new T.MeshBasicMaterial({ color: 0xffb14a, transparent: true, opacity: 0.92 })
);
flame.position.set(cell.x, 1.55, cell.z);
this.fixtureLayer.add(flame);
cell.flame = flame;
this._flames[k] = { bowl: bowl, flame: flame };
}
for (k in this._flames) {
if (want[k]) continue;
var rec = this._flames[k];
this.fixtureLayer.remove(rec.bowl);
this.fixtureLayer.remove(rec.flame);
this._disposeObject(rec.bowl);
this._disposeObject(rec.flame);
delete this._flames[k];
}
};
ThreeRenderer.prototype._fallbackAvatar = function (e, visual) {
var T = this.THREE;
var g = new T.Group();
var body = new T.Mesh(new T.CapsuleGeometry(0.40, 1.05, 4, 12), this._mat(tintForVisual(visual, e.color || '#9aa4b2')));
body.position.y = 0.95;
body.castShadow = true;
g.add(body);
var face = new T.Mesh(new T.BoxGeometry(0.22, 0.12, 0.06), this._mat('#0a0c0f'));
face.position.set(0, 1.30, 0.40);
g.add(face);
return g;
};
// Monsters share the dungeon with the player, so they must never read as a second hero: a
// hunched dark-red body with glowing eyes and its own red spill light.
ThreeRenderer.prototype._makeMonster = function (e) {
var T = this.THREE;
var g = new T.Group();
var body = new T.Mesh(
new T.CapsuleGeometry(0.42, 0.62, 4, 12),
new T.MeshStandardMaterial({ color: 0x7a1d1d, roughness: 0.65, emissive: 0x2a0505, emissiveIntensity: 0.7 })
);
body.position.y = 0.68;
body.castShadow = true;
g.add(body);
var eyeMat = new T.MeshBasicMaterial({ color: 0xff5b4a });
for (var s = -1; s <= 1; s += 2) {
var eye = new T.Mesh(new T.SphereGeometry(0.07, 8, 8), eyeMat);
eye.position.set(s * 0.15, 0.98, 0.34);
g.add(eye);
}
var horns = new T.MeshStandardMaterial({ color: 0x3d1414, roughness: 0.5 });
for (var h = -1; h <= 1; h += 2) {
var horn = new T.Mesh(new T.ConeGeometry(0.07, 0.28, 6), horns);
horn.position.set(h * 0.22, 1.16, 0);
horn.rotation.z = h * -0.3;
g.add(horn);
}
var light = new T.PointLight(0xff4a3a, 1.4, 4.5, 2);
light.position.set(0, 0.9, 0);
g.add(light);
g._menace = true;
return g;
};
// Dungeon features and items (entrance/exit/treasure/pickups) are set dressing, not avatars, so
// they get their own geometry rather than the humanoid capsule an avatar would draw.
ThreeRenderer.prototype._makeFeature = function (e) {
var T = this.THREE;
var g = new T.Group();
var ch = e.char || '?';
if (ch === '<' || ch === '>') {
var down = ch === '>';
var tone = down ? 0xd8a13f : 0x4fbf62;
var stepMat = new T.MeshStandardMaterial({ color: 0x5d564a, roughness: 0.9 });
for (var i = 0; i < 4; i++) {
var step = new T.Mesh(new T.BoxGeometry(0.82, 0.11, 0.82 - i * 0.17), stepMat);
step.position.set(0, down ? 0.05 - i * 0.09 : 0.05 + i * 0.09, i * 0.09);
step.receiveShadow = true;
g.add(step);
}
// A soft beacon column: from across a dark room you can see where the stairs are without
// the fog-of-war having to leak the whole layout.
if (!this._beamTex) {
this._beamTex = new T.CanvasTexture(beamCanvas(64));
this._textures.push(this._beamTex);
}
var beam = new T.Mesh(
new T.CylinderGeometry(0.30, 0.46, 3.2, 14, 1, true),
new T.MeshBasicMaterial({
color: tone, map: this._beamTex, transparent: true, opacity: 0.5,
side: T.DoubleSide, depthWrite: false, blending: T.AdditiveBlending, fog: false
})
);
beam.position.y = 1.6;
g.add(beam);
g._beam = beam;
var bl = new T.PointLight(tone, 1.1, 5, 2);
bl.position.set(0, 0.8, 0);
g.add(bl);
g._pulse = bl;
} else if (ch.charAt(0) === '$') {
var wood = new T.MeshStandardMaterial({ color: 0x6b4423, roughness: 0.8 });
var gold = new T.MeshStandardMaterial({
color: 0xf5c624, roughness: 0.28, metalness: 0.85,
emissive: 0x6b4c07, emissiveIntensity: 0.7
});
var body = new T.Mesh(new T.BoxGeometry(0.62, 0.34, 0.44), wood);
body.position.y = 0.24;
body.castShadow = true;
g.add(body);
var lid = new T.Mesh(new T.CylinderGeometry(0.22, 0.22, 0.62, 12, 1, false, 0, Math.PI), gold);
lid.rotation.z = Math.PI / 2;
lid.position.y = 0.41;
g.add(lid);
var glow = new T.PointLight(0xffc65c, 1.6, 4.5, 2);
glow.position.set(0, 0.7, 0);
g.add(glow);
g._pulse = glow;
g._bob = true;
} else {
var item = new T.Mesh(
new T.OctahedronGeometry(0.22, 0),
new T.MeshStandardMaterial({
color: colorNum(e.color || '#fbbf24'), roughness: 0.3, metalness: 0.6,
emissive: colorNum(e.color || '#fbbf24'), emissiveIntensity: 0.55
})
);
item.position.y = 0.5;
item.castShadow = true;
g.add(item);
g._spin = item;
g._bob = true;
}
g._static = true;
return g;
};
ThreeRenderer.prototype._applyModelTint = function (model, visual) {
var tint = tintForVisual(visual, null);
if (!model) return;
var T = this.THREE;
var color = tint ? new T.Color(colorNum(tint)) : null;
model.traverse(function (obj) {
if (!obj.isMesh || !obj.material) return;
obj.castShadow = true;
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
var next = materials.map(function (mat) {
if (!mat) return mat;
var copy = mat.clone();
if (color && copy.color) copy.color.copy(color);
return copy;
});
obj.material = Array.isArray(obj.material) ? next : next[0];
});
};
ThreeRenderer.prototype._fitModel = function (model) {
var T = this.THREE;
var wrapper = new T.Group();
model.updateMatrixWorld(true);
var box = new T.Box3();
var foundMesh = false;
model.traverse(function (obj) {
if (!obj.isMesh || !obj.geometry) return;
if (!obj.geometry.boundingBox) obj.geometry.computeBoundingBox();
var meshBox = obj.geometry.boundingBox.clone();
meshBox.applyMatrix4(obj.matrixWorld);
box.union(meshBox);
foundMesh = true;
});
if (!foundMesh) box.setFromObject(model);
var size = new T.Vector3();
var center = new T.Vector3();
box.getSize(size);
box.getCenter(center);
var maxDim = Math.max(size.x, size.y, size.z) || 1;
var scale = 1.8 / maxDim;
model.position.set(-center.x, -box.min.y, -center.z);
wrapper.scale.setScalar(scale);
wrapper.add(model);
return wrapper;
};
ThreeRenderer.prototype._loadModel = function (resolved, cb) {
if (!resolved || !resolved.model || !resolved.model.url || !this.GLTFLoader) { cb(null); return; }
var url = resolved.model.url;
if (this.models[url]) {
if (this.models[url].ready) cb(this.models[url]);
else this.models[url].cbs.push(cb);
return;
}
var rec = this.models[url] = { ready: false, cbs: [cb] };
var loader = new this.GLTFLoader();
loader.load(url, function (gltf) {
rec.ready = true;
rec.gltf = gltf;
var cbs = rec.cbs; rec.cbs = [];
cbs.forEach(function (fn) { fn(rec); });
}, null, function () {
rec.ready = true;
rec.error = true;
var cbs = rec.cbs; rec.cbs = [];
cbs.forEach(function (fn) { fn(null); });
});
};
ThreeRenderer.prototype._playAction = function (shell, name) {
if (!shell || !shell._actions || shell._activeActionName === name) return;
var next = shell._actions[name] || shell._actions[Object.keys(shell._actions)[0]];
if (!next) return;
if (shell._activeAction && shell._activeAction !== next) shell._activeAction.fadeOut(0.12);
next.reset().fadeIn(0.12).play();
shell._activeAction = next;
shell._activeActionName = name;
};
ThreeRenderer.prototype._visualFor = function (e) {
var appearance = (e && e.appearance) || { avatar: (e && e.avatar) || 'default' };
if (root.RK && RK.avatarVisuals && RK.avatarVisuals.resolve) {
return RK.avatarVisuals.resolve(appearance, { projection: '3d', context: 'tavern', entity: e });
}
if (root.RK && RK.resolveAppearance) return RK.resolveAppearance(e, '3d');
return null;
};
ThreeRenderer.prototype._makeAvatar = function (e) {
var T = this.THREE, self = this;
var shell = new T.Group();
var visual = this._visualFor(e);
shell._isAvatar = true;
shell._body = this._fallbackAvatar(e, visual);
shell.add(shell._body);
if (visual && visual.allowed !== false && visual.model) {
this._loadModel(visual, function (rec) {
if (!rec || !rec.gltf || !shell.parent) return;
var model = self.SkeletonUtils && self.SkeletonUtils.clone
? self.SkeletonUtils.clone(rec.gltf.scene)
: rec.gltf.scene.clone(true);
self._applyModelTint(model, visual);
model.rotation.y = Math.PI;
var fitted = self._fitModel(model);
if (shell._body) {
shell.remove(shell._body);
self._disposeObject(shell._body);
shell._body = null;
}
shell.add(fitted);
shell._model = fitted;
if (rec.gltf.animations && rec.gltf.animations.length) {
var mixer = new T.AnimationMixer(model);
shell._mixer = mixer;
shell._actions = {};
rec.gltf.animations.forEach(function (clip) {
shell._actions[String(clip.name).toLowerCase()] = mixer.clipAction(clip);
});
self._playAction(shell, 'idle');
self.mixers.push(mixer);
}
});
}
return shell;
};
ThreeRenderer.prototype._makeEntity = function (e) {
if (e.kind === 'feature' || e.kind === 'item') return this._makeFeature(e);
if (e.kind === 'monster') return this._makeMonster(e);
return this._makeAvatar(e);
};
ThreeRenderer.prototype.render = function (scene) {
if (!scene || !this.renderer) return;
this._fitToHost();
// Track the followed player's CELL first: the wall cutaway is computed from it during the
// tile build, so it has to be current before _buildTiles runs.
var pcell = null, i;
for (i = 0; i < scene.entities.length; i++) {
var pe = scene.entities[i];
if (pe.you || pe.cameraTarget || (!pcell && pe.kind === 'player')) pcell = pe;
if (pe.you || pe.cameraTarget) break;
}
if (pcell) { this._plx = pcell.x; this._ply = pcell.y; }
// Rewrite the level every render. `render` is called on game updates (a keypress), not per
// frame, and the buckets are three reused InstancedMeshes, so this is a few thousand matrix
// writes, not an allocation. It has to be unconditional: `lightGrid` re-shades remembered
// tiles on every step, so keying the rebuild on the grid alone would freeze the fog-of-war
// fade at whatever it was when a cell was first seen.
this._buildTiles(scene);
var seen = {}, cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
for (i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i], id = e.id;
seen[id] = true;
var ent = this.entities[id];
if (!ent) {
var obj = this._makeEntity(e);
obj.position.set(e.x - cx, 0, e.y - cz);
ent = this.entities[id] = { obj: obj, x: e.x, y: e.y };
this.entityLayer.add(ent.obj);
}
ent.e = e;
ent.tx = e.x - cx;
ent.tz = e.y - cz;
}
for (id in this.entities) {
if (!seen[id]) {
var removed = this.entities[id].obj;
this.entityLayer.remove(removed);
if (removed._mixer) {
removed._mixer.stopAllAction();
var mixerIndex = this.mixers.indexOf(removed._mixer);
if (mixerIndex >= 0) this.mixers.splice(mixerIndex, 1);
}
this._disposeObject(removed);
delete this.entities[id];
}
}
};
// Reassign the small torch-light pool to whichever braziers are nearest the followed player.
ThreeRenderer.prototype._updateTorchLights = function (px, pz, now) {
var lights = this.torchLights;
if (!lights || !lights.length) return;
var cells = this.torchCells || [];
var near = [];
for (var i = 0; i < cells.length; i++) {
var d = Math.abs(cells[i].x - px) + Math.abs(cells[i].z - pz);
if (d < 16) near.push({ c: cells[i], d: d });
}
near.sort(function (a, b) { return a.d - b.d; });
for (var li = 0; li < lights.length; li++) {
var slot = near[li];
var light = lights[li];
if (!slot) { light.visible = false; light.intensity = 0; continue; }
var flicker = 0.78 + Math.sin(now / 90 + slot.c.gx * 3.1) * 0.13 + Math.sin(now / 37 + slot.c.gy) * 0.07;
light.visible = true;
light.position.set(slot.c.x, 1.5, slot.c.z);
light.intensity = 3.6 * flicker * Math.max(0.3, slot.c.b);
if (slot.c.flame) {
slot.c.flame.scale.set(1, 0.85 + flicker * 0.3, 1);
slot.c.flame.material.opacity = 0.7 + flicker * 0.25;
}
}
};
ThreeRenderer.prototype._animate = function () {
if (!this.renderer) return;
try {
var dt = this.clock.getDelta();
var now = Date.now();
for (var i = 0; i < this.mixers.length; i++) this.mixers[i].update(dt);
for (var id in this.entities) {
var ent = this.entities[id], o = ent.obj;
var dx = (ent.tx || 0) - o.position.x;
var dz = (ent.tz || 0) - o.position.z;
var moving = Math.abs(dx) + Math.abs(dz) > 0.025;
o.position.x += dx * 0.18;
o.position.z += dz * 0.18;
// Set dressing bobs and spins on its own clock; only characters get the gait bounce.
if (o._static) {
o.position.y = o._bob ? 0.06 + Math.sin(now / 520 + o.position.x) * 0.06 : 0;
if (o._spin) o._spin.rotation.y = now / 900;
if (o._beam) o._beam.material.opacity = 0.34 + Math.abs(Math.sin(now / 900)) * 0.26;
if (o._pulse) o._pulse.intensity = 1.1 + Math.abs(Math.sin(now / 700)) * 0.7;
continue;
}
o.position.y = moving
? Math.abs(Math.sin(now / 95)) * 0.08
: Math.sin(now / 620) * 0.018;
if (o._menace) o.position.y += Math.sin(now / 380) * 0.03;
if (o._body) {
o._body.rotation.z = moving ? Math.sin(now / 120) * 0.055 : 0;
}
if (o._model) {
o._model.rotation.z = moving ? Math.sin(now / 120) * 0.045 : 0;
}
this._playAction(o, moving ? 'run' : 'idle');
// Face the way you move. Prefer explicit facing (tavern); else infer from the world delta
// (the SP game does not send player.facing). world x=grid x, world z=grid y.
var face = ent.e && ent.e.facing;
if (!face && moving) { face = Math.abs(dx) >= Math.abs(dz) ? (dx > 0 ? 'right' : 'left') : (dz > 0 ? 'down' : 'up'); ent._face = face; }
face = face || ent._face;
if (face) {
var r = face === 'up' ? Math.PI : face === 'left' ? -Math.PI / 2 : face === 'right' ? Math.PI / 2 : 0;
o.rotation.y += (r - o.rotation.y) * 0.18;
}
}
// Player-follow camera: keep the fixed iso offset but re-target the player's world position
// each frame. The player mesh itself lerps, so the camera glides smoothly with it.
this._fitToHost(); // picks up any host resize
var pcam = null;
for (var pid in this.entities) {
var pen = this.entities[pid];
if (pen.e && (pen.e.you || pen.e.cameraTarget)) { pcam = pen.obj; break; }
if (!pcam && pen.e && pen.e.kind === 'player') pcam = pen.obj;
}
if (pcam) {
// Dragging the viewport should move the rendered dungeon with the pointer. Translate the
// camera target opposite the corresponding screen-space axes while retaining the fixed
// isometric camera offset and player-follow smoothing.
var viewportHeight = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
var unitsPerPixel = (13.2 / (this.zoom || 1)) / Math.max(1, viewportHeight);
var diagonal = unitsPerPixel / Math.sqrt(2);
var targetX = pcam.position.x + (-this.panX - this.panY) * diagonal;
var targetZ = pcam.position.z + (this.panX - this.panY) * diagonal;
this.camera.position.set(targetX + 9, 11, targetZ + 9);
this.camera.lookAt(targetX, 0, targetZ);
if (this.playerLight) {
this.playerLight.position.set(pcam.position.x, 2.6, pcam.position.z);
// Hand-held torch: a slow irregular flicker, not a strobe.
this.playerLight.intensity = 4.2 + Math.sin(now / 140) * 0.3 + Math.sin(now / 61) * 0.15;
}
// Keep the shadow frustum tight around the player so a 1024 map stays sharp on a big level.
if (this.keyLight) {
this.keyLight.position.set(pcam.position.x + 10, 8.5, pcam.position.z + 4);
this.keyLight.target.position.set(pcam.position.x, 0, pcam.position.z);
this.keyLight.target.updateMatrixWorld();
}
if (this.motes) {
this.motes.position.set(pcam.position.x, 0, pcam.position.z);
this.motes.rotation.y = now / 60000;
}
this._updateTorchLights(pcam.position.x, pcam.position.z, now);
}
this.renderer.render(this.scene, this.camera);
} catch (err) {
if (root.console) console.warn('3D animate error; stopping 3D loop:', err && err.message);
return false;
}
return true;
};
ThreeRenderer.prototype.destroy = function () {
this._destroyed = true;
if (this._raf) cancelAnimationFrame(this._raf);
this._raf = null;
for (var i = 0; i < this.mixers.length; i++) this.mixers[i].stopAllAction();
this._disposeObject(this.world);
for (var name in this.buckets) {
if (this.buckets[name].mesh.dispose) this.buckets[name].mesh.dispose();
}
this.buckets = {};
for (var g in (this._geom || {})) this._geom[g].dispose();
this._geom = null;
[this.wallMat, this.groundMat, this.propMat, this.glowMat, this._moteMat].forEach(function (m) {
if (m && m.dispose) m.dispose();
});
(this._textures || []).forEach(function (t) { if (t && t.dispose) t.dispose(); });
this._textures = [];
if (this.motes && this.motes.geometry) this.motes.geometry.dispose();
if (this.renderer) {
if (this.renderer.domElement && this.renderer.domElement.parentNode) this.renderer.domElement.parentNode.removeChild(this.renderer.domElement);
this.renderer.dispose();
if (this.renderer.forceContextLoss) this.renderer.forceContextLoss();
}
this.renderer = null;
this.entities = {};
this.models = {};
this.mixers = [];
this.torchCells = [];
};
root.RK = root.RK || {};
root.RK.ThreeRenderer = ThreeRenderer;
})(window);