mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.
CRITICAL — unauthenticated remote kill switch
Socket.IO does not wrap listener invocation, and this process treats
uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
listener exits the server. `socket.emit('debug_ping')` with no argument did
exactly that, and systemd restarts straight back into it. Every registration
in handleConnection now goes through a safe-dispatch wrapper that contains
sync throws and rejected promises to the offending socket; handleDebugPing
also stops trusting its payload.
HIGH
- NIP-98 login now binds the signed event to this deployment's host. The path
check alone let an event signed for any other origin authenticate here, and
linking ADOPTS the account owning that pubkey — full account takeover.
- Fog of war is enforced server-side. getState() shipped absolute
entrance/exit/treasure coordinates and the monster's exact position in every
frame, with concealment implemented only in the browser. Discovery is sticky;
the monster is reported only while in view.
- NODE_ENV must now be explicit and recognized. Unset, every production gate
read false while DebugManager reported production and silenced logging.
- Tavern chat applies the lobby's ban list, rate limiter and user_id
attribution before entering global chat.
- userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
by socket.client.id, so nothing was ever freed).
MEDIUM / LOW
- Payouts: a legitimately split transfer_split kept its transaction hashes
instead of quarantining already-broadcast funds with tx_hash NULL, and
provably pre-broadcast failures return to 'pending' rather than needs_review.
- RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
never reset, because ids contain colons.
- Enter no longer submits a paid entry from behind a modal.
- Lobby chat no longer double-escapes server-escaped text.
- Rate limits for identity:get / address:prompt / early_entry.
- Spectators released when a game ends by disconnect, not just settlement.
- movementManager / addressManager per-socket maps cleaned on disconnect.
Renderers
Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
light pools, crevice AO, embers, cold memory wash, and it stops reallocating
the whole canvas every animation frame.
3D: instanced level geometry (3 draw calls), procedural stone/flagstone
materials, torch lights + shadows, real meshes for stairs/chest/monster
(they were humanoid capsules), dust motes, wall cutaway, and lighting
rebalanced now that fog is measured from the camera.
Tavern
Block height and hash fragments drift around the stage edge and ripple when a
block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
Speech bubbles work again (they keyed off a socketId the server stopped
sending).
126 suites / 1005 tests pass.
956 lines
44 KiB
JavaScript
956 lines
44 KiB
JavaScript
// ThreeRenderer — a lit, textured 3D projection of the shared Scene. It uses generated GLB avatars
|
|
// when available and falls back to low-poly pieces so the mode remains usable during asset work.
|
|
//
|
|
// 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
|
|
// doesn't 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 and 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.
|
|
// Fog is measured from the CAMERA, which sits ~17 units back along the isometric offset —
|
|
// not from the player. A band tuned as if it started at the player put the player themselves
|
|
// a quarter of the way into fog and crushed the whole 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 (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 → 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. Keep that contract for 3D and convert the
|
|
// offset to ground-plane world units in _animate(), where the followed player's current smoothed
|
|
// position is available. Bound it to one viewport so a stray gesture cannot lose 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 is what
|
|
// 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 render it (fog-of-war)
|
|
var def = legend[kind] || {};
|
|
var lb = (light && light[y] && light[y][x] != null) ? light[y][x] : 1;
|
|
// Unlike the 2D modes — where lightGrid IS the lighting — 3D has real point lights
|
|
// doing the falloff, so applying the grid at full strength to the albedo as well
|
|
// double-darkened every surface into near-black. Compress it into a gentle range and
|
|
// let 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 can follow 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 the cells that newly appeared 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/ITEMS (entrance/exit/treasure/pickups) are set dressing, not avatars. Before
|
|
// this branch existed every one of them was drawn as a humanoid capsule, so the entrance looked
|
|
// like a second player standing at the map origin.
|
|
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 froze 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 doesn't 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(); // pick 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);
|