Files
jwinterm fad2a695bd Wownerogue
A browser-based roguelike synchronized with crypto block timing. A run is a race
against the next block: reach the exit before the block lands, or the house wins.
The same codebase runs the solo dungeon, the social Tavern, and operator-selected
PvP and race rulesets, with separate free and paid leaderboards.

Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md
for the design, and docs/DEPLOY.md to operate an instance.
2026-07-27 08:23:01 -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);