diff --git a/docs/RENDER_PACKS.md b/docs/RENDER_PACKS.md
index 471fff5..a8bbafd 100644
--- a/docs/RENDER_PACKS.md
+++ b/docs/RENDER_PACKS.md
@@ -66,6 +66,48 @@ draws only the tile's floor **base** in the static pass and lets `RK.fx` paint t
the live loop. The tavern has wall braziers + a hearth; the dungeon hazard kinds are wired and render
the moment the generator emits their chars (`L`/`P`/`^`) — pending the generator + gameplay stakes.
+## Lighting model (iso & 3D)
+
+Both premium projections light the dungeon rather than just tinting it, and both derive their
+lighting from the same `scene.lightGrid` the 2D modes use — so switching projection changes the
+technique, never what you are allowed to see.
+
+**Iso** composites in one pass per frame:
+
+- tiles are **shaded opaque** (`_shadedImage`, quantized to 6 cached levels) instead of alpha-faded.
+ Alpha-fading made remembered walls translucent, so the dungeon read as a haze of overlapping
+ ghosts; multiplying a sprite's own pixels toward a cold near-black keeps distant stone *solid and
+ unlit*, which is what memory should look like;
+- **light pools** (`_lightPool`) are additive, squashed to the ground plane, and emitted by torches
+ (flickering), the player's lantern, and objective beacons on the stairs/treasure;
+- **crevice AO** (`_wallAO`) darkens floor cells by how many walls they touch;
+- **embers** rise off each flame, seeded per cell so they don't reshuffle every frame;
+- a **cold blue wash** (`_memoryWash`) separates remembered rooms from torchlit ones by colour
+ temperature. The wash colour must stay *darker* than the tiles it covers — a mid-tone blue lifts
+ memory brighter than the lit centre and inverts the depth hierarchy.
+
+**3D** (`threeRenderer.js`) draws the level as **three InstancedMeshes** (walls / ground / props),
+so a 70×35 dungeon costs three draw calls and a fog-of-war update is a few thousand matrix writes
+rather than ~2500 mesh allocations. Wall and floor materials are generated on a canvas at startup
+(coursed stone, irregular flagstones, both doubling as bump maps) — no new downloads, nothing for
+the CSP to block. On top: a low warm key light with a player-tracking shadow frustum, a pool of six
+point lights reassigned each frame to the nearest braziers, a hand-held lantern, drifting dust motes,
+and a **wall cutaway** that squashes the walls between the isometric camera and the player.
+
+Two things that are easy to get wrong here:
+
+- **Fog is measured from the CAMERA**, which sits ~17 world units back along the isometric offset —
+ not from the player. A fog band tuned as if it started at the player puts the player a quarter of
+ the way into fog and crushes the scene to near-black.
+- **Do not apply `lightGrid` to albedo at full strength in 3D.** The point lights already provide
+ falloff; multiplying by the grid as well double-darkens every surface. 3D compresses it
+ (`0.38 + 0.62·lb`) and lets the lamps shape the scene.
+
+Features (`entrance` / `exit` / `treasure` / items) are **set dressing, not avatars** in both
+projections: stairs with a beacon shaft, a gold chest, a spinning pickup. Before that distinction
+existed, every one of them was drawn as a humanoid, so the entrance looked like a second player
+standing at the map origin.
+
## Other pieces
| File | Role |
@@ -75,7 +117,8 @@ the moment the generator emits their chars (`L`/`P`/`^`) — pending the generat
| `zoomControl.js` | `RK.attachZoom(host)` — wheel/pinch zoom (0.4–4.0), dblclick reset, pixelated scaling. |
| `catSprites.js` | Animated tavern cat (Pet Cats Pack idle strips). |
| `packRegistry.js`| Multi-pack-per-projection registry: active-pack selection persisted per projection, entitlement-gated, graceful fallback. Node-testable. |
-| `threeRenderer.js` | Same-origin Three.js scene, fog-aware entity retention, animated GLB cloning, fitted model scale, camera follow, and complete renderer/resource cleanup. |
+| `threeRenderer.js` | Same-origin Three.js scene. Instanced level geometry, procedural stone/flagstone materials, torch light pool + shadows, dedicated feature/monster meshes, dust motes, wall cutaway, fog-aware entity retention, animated GLB cloning, camera follow, and complete renderer/resource cleanup. |
+| `chainAmbience.js` | `RK.mountChainAmbience(host)` — drifting top-block-hash fragments, a block-height readout, and a ripple when a block lands. Consumes the public `blockheight` broadcast; decoration only, and it pauses/prunes itself while the tab is hidden. |
| `spGameRenderer.js` | Single-player render bridge, player-centred camera, local 3D lazy-load, and explicit gameplay-focus restoration. |
Tests: `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`,
diff --git a/docs/TAVERN_AND_MULTIPLAYER.md b/docs/TAVERN_AND_MULTIPLAYER.md
index eafad22..efa9f13 100644
--- a/docs/TAVERN_AND_MULTIPLAYER.md
+++ b/docs/TAVERN_AND_MULTIPLAYER.md
@@ -157,6 +157,33 @@ render modes, and later multiplayer rooms.
The existing chat backend is retained and wrapped behind a `ChatProvider` interface so the backend
can be swapped later without changing callers. The current backend remains the default.
+**Tavern chat is global chat.** When a `globalChatProvider` is injected, `tavern_chat` reaches every
+connected client and the persisted history, so it must clear the same bar as the lobby path:
+`TavernManager._moderateChat` applies the chat ban list, the reconnect-proof rate limiter
+(`stableId` + IP), and `user_id` attribution before publishing. Without those guards `tavern_chat` is
+simply an unauthenticated bypass of lobby moderation under a client-chosen display name. The
+per-occupant cooldown alone still governs the ephemeral tavern-scoped fallback room, which nobody
+outside the room ever sees.
+
+Speech bubbles resolve the speaker from the occupant list **by display name**. The chat provider
+publishes a short `publicId`, never the raw socket id, so anything keying bubbles off `socketId`
+silently never fires.
+
+## Chain ambience
+
+The Tavern stage carries a decorative chain layer (`RK.mountChainAmbience`, `chainAmbience.js`):
+fragments of the real top block hash fade in around the border and drift away, a corner readout
+tracks the height, and a landing block sends a ripple around the frame plus a brighter burst.
+
+It consumes the public `blockheight` broadcast, which now carries an optional cosmetic tip
+(`hash`, `difficulty`, `txPoolSize`). The tip is refreshed from `get_info` **only when the height
+actually advances** — it is decoration, not worth an extra RPC every poll — and
+`RpcService.getChainTipInfo()` never throws and never feeds fairness, payouts, or match seeding.
+`DebugManager.getChainTip()` returns null under simulated blocks, since there is no chain to
+describe. The layer is `pointer-events:none`, honours `prefers-reduced-motion`, and stops spawning
+(and drops its backlog) while the tab is hidden — a hidden tab pauses CSS animations, so
+`animationend` never fires and motes would otherwise accumulate indefinitely.
+
## Rendering (art pass)
Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted
diff --git a/html/js/input/inputHandler.js b/html/js/input/inputHandler.js
index 187487a..e4fb002 100644
--- a/html/js/input/inputHandler.js
+++ b/html/js/input/inputHandler.js
@@ -1,6 +1,34 @@
/**
* Input handlers for the Wowngeon game
*/
+
+// Is a blocking dialog on screen?
+//
+// The document-level Enter shortcut submits a RANKED (paid-intent) entry, and it used to fire
+// regardless of what was on top — including while the entry-choice modal or the payment modal was
+// open. Since those modals advertise "ENTER ▸" on their own cards, one Enter silently submitted an
+// entry the player had not chosen, and the click that followed submitted a second one. Anything
+// that takes over the screen must swallow the shortcut.
+function modalIsOpen() {
+ if (document.getElementById('entryChoiceOverlay')) return true;
+ var overlays = document.querySelectorAll('.modal-overlay');
+ for (var i = 0; i < overlays.length; i++) {
+ if (!overlays[i].classList.contains('hidden')) return true;
+ }
+ var paymentUi = document.getElementById('payment-ui');
+ if (paymentUi && paymentUi.offsetParent !== null) return true;
+ return false;
+}
+
+// Enter inside any text field belongs to that field, not to the game.
+function typingInAField() {
+ var el = document.activeElement;
+ if (!el) return false;
+ if (el.isContentEditable) return true;
+ var tag = (el.tagName || '').toLowerCase();
+ return tag === 'input' || tag === 'textarea' || tag === 'select';
+}
+
const InputHandler = {
_lastMoveTime: 0,
_moveCooldown: 100, // Minimum 100ms between moves
@@ -151,12 +179,13 @@ const InputHandler = {
}
}
}
- } else if (e.key === 'Enter' && document.activeElement !== $('#chatInput')[0]) {
+ } else if (e.key === 'Enter' && document.activeElement !== $('#chatInput')[0]
+ && !modalIsOpen() && !typingInAField()) {
// If Enter is pressed and chat is not focused
if (typeof Game !== 'undefined' && !Game._gameActive) {
// On welcome screen - start the game
e.preventDefault();
-
+
const isDebugMode = window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.protocol === 'file:';
diff --git a/html/js/network/socketHandlers.js b/html/js/network/socketHandlers.js
index 3836b55..9b41ba5 100644
--- a/html/js/network/socketHandlers.js
+++ b/html/js/network/socketHandlers.js
@@ -494,6 +494,29 @@ const SocketHandlers = {
}
},
+ // Chat text arrives ALREADY HTML-escaped by the server (that is the delivery contract — the
+ // tavern renders it as HTML directly). Escaping it a second time on the way in is what made
+ // every apostrophe show up as ' and every ampersand as &. Decode the exact five
+ // entities the server writes, then render through a TEXT node: no double encoding, and strictly
+ // safer than the old innerHTML path because the message can never be parsed as markup at all —
+ // so the client keeps its "never trust the server escaped it" stance without the artefacts.
+ _chatText: function(value) {
+ return String(value == null ? '' : value)
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/&/g, '&'); // last — mirrors the server escaping & first
+ },
+
+ // sender: followed by the message as a text node.
+ _chatLine: function(msgElement, sender, message) {
+ const strong = document.createElement('strong');
+ strong.textContent = SocketHandlers._chatText(sender) + ':';
+ msgElement.append(strong);
+ msgElement.append(document.createTextNode(' ' + SocketHandlers._chatText(message)));
+ },
+
onChatBroadcast: function(data) {
const msgElement = $('
');
// Prefer non-sensitive attribution fields; the server no longer sends the raw full
@@ -501,11 +524,9 @@ const SocketHandlers = {
const sender = data.username || data.playerId || data.publicId ||
(data.socketId ? String(data.socketId).substring(0, 6) : null);
if (sender) {
- // Escape both fields — chat content is attacker-controlled. Defense in depth:
- // the server also escapes, but the client must never trust that.
- msgElement.html('' + escapeHtml(String(sender)) + ': ' + escapeHtml(data.message));
+ SocketHandlers._chatLine(msgElement, String(sender), data.message);
} else {
- msgElement.text(data.message);
+ msgElement.text(SocketHandlers._chatText(data.message));
}
$('#messages').append(msgElement);
UI.scrollChat();
@@ -531,11 +552,13 @@ const SocketHandlers = {
msgElement.addClass('status');
}
// Add timestamp if available
- let timeStr = '';
if (msg.timestamp) {
- timeStr = '[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + '] ';
+ const stamp = document.createElement('span');
+ stamp.style.cssText = 'color:#555;font-size:10px;';
+ stamp.textContent = '[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + '] ';
+ msgElement.append(stamp);
}
- msgElement.html(timeStr + '' + escapeHtml(username) + ': ' + escapeHtml(msg.message));
+ SocketHandlers._chatLine(msgElement, username, msg.message);
$('#messages').append(msgElement);
});
diff --git a/html/js/render/chainAmbience.js b/html/js/render/chainAmbience.js
new file mode 100644
index 0000000..0c39e0c
--- /dev/null
+++ b/html/js/render/chainAmbience.js
@@ -0,0 +1,217 @@
+// ChainAmbience — the live chain, drifting around the edges of a stage.
+//
+// The Tavern sits on top of a real blockchain, and the game's whole clock is "when does the next
+// block land". This makes that visible without putting a dashboard on screen: fragments of the top
+// block hash fade in around the border and drift away, a corner readout tracks the height, and a
+// new block sends a ripple around the frame.
+//
+// Purely decorative. It reads the public `blockheight` broadcast and never influences gameplay, so
+// a missing or stale tip just means fewer motes.
+(function (root) {
+ 'use strict';
+ var RK = root.RK = root.RK || {};
+
+ var STYLE_ID = 'rk-chain-ambience-style';
+ var CSS = [
+ '.rk-chain-amb{position:absolute;inset:0;pointer-events:none;overflow:hidden;z-index:9;',
+ ' font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}',
+ '.rk-chain-amb .rk-cm{position:absolute;white-space:nowrap;font-size:11px;letter-spacing:.09em;',
+ ' color:#7fe3c0;opacity:0;text-shadow:0 0 8px rgba(80,220,180,.45);will-change:transform,opacity;}',
+ '.rk-chain-amb .rk-cm.rk-drift{animation:rk-cm-drift var(--rk-cm-life,9s) linear forwards;}',
+ '@keyframes rk-cm-drift{',
+ ' 0%{opacity:0;transform:translate3d(0,0,0) scale(.94);}',
+ ' 16%{opacity:var(--rk-cm-peak,.42);}',
+ ' 76%{opacity:var(--rk-cm-peak,.42);}',
+ ' 100%{opacity:0;transform:translate3d(var(--rk-cm-dx,0px),var(--rk-cm-dy,-26px),0) scale(1);}}',
+ '.rk-chain-amb .rk-chain-readout{position:absolute;left:10px;bottom:8px;font-size:10px;',
+ ' line-height:1.5;color:#5f7f96;letter-spacing:.1em;opacity:.72;transition:color .5s ease;}',
+ '.rk-chain-amb .rk-chain-readout b{display:block;color:#8fd8b6;font-weight:400;font-size:11px;}',
+ '.rk-chain-amb .rk-chain-readout .rk-chain-hash{display:block;color:#4d6a80;word-break:break-all;max-width:34ch;}',
+ '.rk-chain-amb .rk-chain-readout.rk-fresh b{color:#b6f7d6;text-shadow:0 0 10px rgba(120,240,190,.55);}',
+ '.rk-chain-amb .rk-chain-ripple{position:absolute;inset:0;border:1px solid rgba(127,227,192,0);',
+ ' border-radius:inherit;}',
+ '.rk-chain-amb .rk-chain-ripple.rk-go{animation:rk-chain-ripple 1.5s ease-out forwards;}',
+ '@keyframes rk-chain-ripple{',
+ ' 0%{border-color:rgba(127,227,192,.55);box-shadow:inset 0 0 0 rgba(127,227,192,.4);}',
+ ' 100%{border-color:rgba(127,227,192,0);box-shadow:inset 0 0 46px rgba(127,227,192,0);}}',
+ '@media (prefers-reduced-motion:reduce){',
+ ' .rk-chain-amb .rk-cm{display:none}',
+ ' .rk-chain-amb .rk-chain-ripple.rk-go{animation:none}}'
+ ].join('\n');
+
+ function ensureStyle(doc) {
+ if (doc.getElementById(STYLE_ID)) return;
+ var el = doc.createElement('style');
+ el.id = STYLE_ID;
+ el.textContent = CSS;
+ doc.head.appendChild(el);
+ }
+
+ function reducedMotion() {
+ try { return root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches; }
+ catch (_) { return false; }
+ }
+
+ function ChainAmbience(host, opts) {
+ opts = opts || {};
+ var doc = root.document;
+ ensureStyle(doc);
+ this.host = host;
+ this.doc = doc;
+ this.tip = { height: null, hash: null, difficulty: null, txPoolSize: null };
+ this.motes = 0;
+ this.maxMotes = opts.maxMotes || 9;
+ this.spawnMs = opts.spawnMs || 1200;
+
+ this.el = doc.createElement('div');
+ this.el.className = 'rk-chain-amb';
+ this.el.setAttribute('aria-hidden', 'true'); // decoration; the readout below is the a11y copy
+
+ this.ripple = doc.createElement('div');
+ this.ripple.className = 'rk-chain-ripple';
+ this.el.appendChild(this.ripple);
+
+ this.readout = doc.createElement('div');
+ this.readout.className = 'rk-chain-readout';
+ this.heightEl = doc.createElement('b');
+ this.hashEl = doc.createElement('span');
+ this.hashEl.className = 'rk-chain-hash';
+ this.readout.appendChild(this.heightEl);
+ this.readout.appendChild(this.hashEl);
+ this.el.appendChild(this.readout);
+
+ host.appendChild(this.el);
+
+ var self = this;
+ this.timer = root.setInterval(function () { self._spawn(); }, this.spawnMs);
+
+ // A hidden tab pauses CSS animations, so `animationend` never fires and nothing is ever
+ // removed — while the spawn interval keeps ticking. Left alone, a tavern tab parked in the
+ // background accrues motes indefinitely and then plays a pile of stale ones all at once when
+ // the player comes back. Spawning is skipped while hidden (below) and the backlog is dropped
+ // here; these are decorations, so discarding them costs nothing.
+ this._onVisibility = function () {
+ if (!self.el || !self.doc.hidden) return;
+ self._clearMotes();
+ };
+ doc.addEventListener('visibilitychange', this._onVisibility);
+ }
+
+ ChainAmbience.prototype._clearMotes = function () {
+ if (!this.el) return;
+ var existing = this.el.querySelectorAll('.rk-cm');
+ for (var i = 0; i < existing.length; i++) existing[i].remove();
+ this.motes = 0;
+ };
+
+ // Fragments of the real top-block hash, plus the occasional height/difficulty token, so the
+ // drifting text is actual chain data rather than decorative gibberish.
+ ChainAmbience.prototype._fragment = function () {
+ var t = this.tip;
+ var roll = Math.random();
+ if (t.hash && roll < 0.68) {
+ var start = Math.floor(Math.random() * (t.hash.length - 8));
+ return t.hash.substr(start, 4 + Math.floor(Math.random() * 5));
+ }
+ if (t.height != null && roll < 0.86) return '#' + t.height;
+ if (t.difficulty != null && roll < 0.94) return 'diff ' + Math.round(t.difficulty / 1e6) + 'M';
+ if (t.txPoolSize != null) return 'mempool ' + t.txPoolSize;
+ return t.height != null ? '#' + t.height : '';
+ };
+
+ ChainAmbience.prototype._spawn = function (burst) {
+ if (!this.el || reducedMotion()) return;
+ if (this.doc.hidden) return; // paused animations would never clean themselves up
+ // Count the DOM, not a counter: a missed animationend must not permanently inflate the tally
+ // and silently stop all further spawning.
+ if (!burst && this.el.querySelectorAll('.rk-cm').length >= this.maxMotes) return;
+ var text = this._fragment();
+ if (!text) return;
+ var doc = this.doc;
+ var m = doc.createElement('span');
+ m.className = 'rk-cm rk-drift';
+ m.textContent = text;
+
+ // Hug the border: pick an edge, then a position along it. The middle of the stage is where
+ // the game is — the chain lives in the margins.
+ var edge = Math.floor(Math.random() * 4);
+ var along = 6 + Math.random() * 84;
+ var dx = 0, dy = 0;
+ if (edge === 0) { m.style.left = along + '%'; m.style.top = '3%'; dy = -18; }
+ else if (edge === 1) { m.style.left = along + '%'; m.style.bottom = '4%'; dy = 20; }
+ else if (edge === 2) { m.style.left = '2%'; m.style.top = along + '%'; dx = -22; }
+ else { m.style.right = '2%'; m.style.top = along + '%'; dx = 22; }
+ dx += (Math.random() - 0.5) * 14;
+ dy += (Math.random() - 0.5) * 10;
+
+ m.style.setProperty('--rk-cm-dx', dx.toFixed(1) + 'px');
+ m.style.setProperty('--rk-cm-dy', dy.toFixed(1) + 'px');
+ m.style.setProperty('--rk-cm-life', (7 + Math.random() * 5).toFixed(1) + 's');
+ m.style.setProperty('--rk-cm-peak', (burst ? 0.62 : 0.30 + Math.random() * 0.22).toFixed(2));
+ if (burst) m.style.color = '#b6f7d6';
+
+ var self = this;
+ m.addEventListener('animationend', function () {
+ if (m.parentNode) m.parentNode.removeChild(m);
+ self.motes--;
+ });
+ this.motes++;
+ this.el.appendChild(m);
+ };
+
+ // Feed it the `blockheight` payload. A height increase is treated as "a block landed" and gets
+ // the ripple + a burst of fragments; a repeat of the same height just refreshes the readout.
+ ChainAmbience.prototype.setTip = function (data) {
+ if (!this.el || !data) return;
+ var height = Number(data.blockHeight);
+ if (!isFinite(height)) return;
+ var advanced = this.tip.height != null && height > this.tip.height;
+ this.tip.height = height;
+ if (typeof data.hash === 'string') this.tip.hash = data.hash;
+ if (data.difficulty != null) this.tip.difficulty = Number(data.difficulty);
+ if (data.txPoolSize != null) this.tip.txPoolSize = Number(data.txPoolSize);
+
+ this.heightEl.textContent = 'BLOCK ' + height;
+ this.hashEl.textContent = this.tip.hash
+ ? this.tip.hash.slice(0, 16) + '…' + this.tip.hash.slice(-8)
+ : '';
+
+ if (!advanced) return;
+ var self = this;
+ this.readout.classList.add('rk-fresh');
+ this.ripple.classList.remove('rk-go');
+ // Force a reflow so the animation restarts on consecutive blocks.
+ void this.ripple.offsetWidth;
+ this.ripple.classList.add('rk-go');
+ for (var i = 0; i < 5; i++) {
+ (function (n) { root.setTimeout(function () { self._spawn(true); }, n * 110); })(i);
+ }
+ if (this._freshTimer) root.clearTimeout(this._freshTimer);
+ this._freshTimer = root.setTimeout(function () {
+ if (self.readout) self.readout.classList.remove('rk-fresh');
+ }, 2600);
+ };
+
+ ChainAmbience.prototype.destroy = function () {
+ if (this.timer) root.clearInterval(this.timer);
+ this.timer = null;
+ if (this._onVisibility) this.doc.removeEventListener('visibilitychange', this._onVisibility);
+ this._onVisibility = null;
+ if (this._freshTimer) root.clearTimeout(this._freshTimer);
+ this._freshTimer = null;
+ if (this.el && this.el.parentNode) this.el.parentNode.removeChild(this.el);
+ this.el = null;
+ this.readout = null;
+ this.ripple = null;
+ };
+
+ RK.ChainAmbience = ChainAmbience;
+ RK.mountChainAmbience = function (host, opts) {
+ if (!host || !root.document) return null;
+ try { return new ChainAmbience(host, opts); }
+ catch (e) {
+ if (root.console) console.warn('chain ambience unavailable:', e && e.message);
+ return null;
+ }
+ };
+})(typeof window !== 'undefined' ? window : this);
diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js
index ef81ee1..8700494 100644
--- a/html/js/render/isoRenderer.js
+++ b/html/js/render/isoRenderer.js
@@ -17,6 +17,7 @@
this.ctx = this.canvas.getContext('2d');
this.cache = {};
this.tintCache = {};
+ this.shadeCache = {};
this.boundsCache = {};
this.last = {};
this.lastScene = null;
@@ -131,15 +132,78 @@
ctx.restore();
};
+ // Warm pool of light cast onto the scene by a torch / the player's lantern / a beacon. Drawn
+ // additively after the tiles so it lights the floor, the props AND the characters standing in
+ // it, which is what separates a lit room from a flat sprite collage.
+ IsoRenderer.prototype._lightPool = function (cx, cy, radius, inner, outer) {
+ var ctx = this.ctx;
+ // A non-finite emitter would throw out of createRadialGradient and abandon the frame
+ // half-drawn. Lighting is decoration — skip the bad pool, keep the dungeon.
+ if (!isFinite(cx) || !isFinite(cy) || !isFinite(radius) || radius <= 0) return;
+ var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
+ g.addColorStop(0, inner);
+ g.addColorStop(0.45, outer);
+ g.addColorStop(1, 'rgba(0,0,0,0)');
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ ctx.fillStyle = g;
+ // The pool is squashed to the iso ground plane so it reads as light ON the floor rather
+ // than a sphere floating in front of the camera.
+ ctx.translate(cx, cy);
+ ctx.scale(1, this.tileH / this.tileW);
+ ctx.translate(-cx, -cy);
+ ctx.beginPath();
+ ctx.arc(cx, cy, radius, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.restore();
+ };
+
+ // Embers rising off a flame. Deterministic per torch (seeded by cell) so they don't reshuffle
+ // every frame; only the phase advances.
+ IsoRenderer.prototype._embers = function (cx, cy, seed, now) {
+ var ctx = this.ctx;
+ ctx.save();
+ ctx.globalCompositeOperation = 'lighter';
+ for (var i = 0; i < 4; i++) {
+ var phase = ((now / 1400) + (seed + i * 37) / 97) % 1;
+ var driftX = Math.sin((seed + i * 11) * 1.7 + phase * 6.28) * 7;
+ var y = cy - phase * 34;
+ var a = (1 - phase) * 0.5;
+ ctx.fillStyle = 'rgba(255,' + (150 + Math.floor(phase * 70)) + ',90,' + a.toFixed(3) + ')';
+ ctx.beginPath();
+ ctx.arc(cx + driftX, y, 1.5 * (1 - phase * 0.5), 0, Math.PI * 2);
+ ctx.fill();
+ }
+ ctx.restore();
+ };
+
+ // Crevice darkening on floor cells that touch a wall — a cheap ambient-occlusion cue. The
+ // gradient is clear in the middle of the cell and dark at its rim, so tiles tucked into a corner
+ // sit in shadow instead of looking like stickers laid on top of the ground.
+ IsoRenderer.prototype._wallAO = function (cx, cy, strength) {
+ var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
+ var g = ctx.createRadialGradient(cx, cy, hw * 0.15, cx, cy, hw);
+ g.addColorStop(0, 'rgba(0,0,0,0)');
+ g.addColorStop(1, 'rgba(0,0,0,' + strength.toFixed(3) + ')');
+ ctx.save();
+ ctx.beginPath();
+ ctx.moveTo(cx, cy - hh); ctx.lineTo(cx + hw, cy); ctx.lineTo(cx, cy + hh); ctx.lineTo(cx - hw, cy);
+ ctx.closePath();
+ ctx.clip();
+ ctx.fillStyle = g;
+ ctx.fillRect(cx - hw, cy - hh, this.tileW, this.tileH);
+ ctx.restore();
+ };
+
// Warm radial vignette — an ambient tavern glow that darkens the edges and lifts the centre,
// the "juice" that makes the flat canvas feel lit rather than pasted.
IsoRenderer.prototype._vignette = function () {
var ctx = this.ctx, w = this.canvas.width, h = this.canvas.height;
var g = ctx.createRadialGradient(w / 2, h * 0.46, Math.min(w, h) * 0.18,
w / 2, h * 0.46, Math.max(w, h) * 0.62);
- g.addColorStop(0, 'rgba(255, 214, 150, 0.10)');
- g.addColorStop(0.55, 'rgba(0, 0, 0, 0)');
- g.addColorStop(1, 'rgba(0, 0, 0, 0.42)');
+ g.addColorStop(0, 'rgba(255, 214, 150, 0.06)');
+ g.addColorStop(0.5, 'rgba(0, 0, 0, 0)');
+ g.addColorStop(1, 'rgba(0, 0, 0, 0.58)');
ctx.save();
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = g;
@@ -201,6 +265,34 @@
return cv;
};
+ // Darkened copy of a tile image, cached at quantized levels.
+ //
+ // Fog-of-war used to dim tiles with globalAlpha, which made remembered walls TRANSLUCENT — you
+ // could see the tiles behind them, so the dungeon read as a haze of overlapping ghosts instead
+ // of solid stone in the dark. Multiplying the sprite's own pixels toward black keeps every tile
+ // opaque and just unlit, which is what "you remember this room, it isn't lit" should look like.
+ IsoRenderer.prototype._shadedImage = function (img, level) {
+ if (!img) return img;
+ var step = Math.max(0, Math.min(5, Math.round(level * 5)));
+ if (step >= 5) return img;
+ var key = (img.src || img._rkBoundsKey || '') + '|s' + step;
+ if (this.shadeCache[key]) return this.shadeCache[key];
+ var cv = document.createElement('canvas');
+ cv.width = img.naturalWidth || img.width;
+ cv.height = img.naturalHeight || img.height;
+ if (!cv.width || !cv.height) return img;
+ var ctx = cv.getContext('2d');
+ ctx.drawImage(img, 0, 0);
+ ctx.globalCompositeOperation = 'source-atop';
+ // A touch of blue in the shadow rather than flat black — unlit stone reads cold.
+ ctx.fillStyle = 'rgba(6,10,20,' + (1 - step / 5).toFixed(3) + ')';
+ ctx.fillRect(0, 0, cv.width, cv.height);
+ ctx.globalCompositeOperation = 'source-over';
+ cv._rkBoundsKey = key;
+ this.shadeCache[key] = cv;
+ return cv;
+ };
+
IsoRenderer.prototype._skinTintFor = function (visual) {
var ap = visual && visual.appearance;
var skin = ap && ap.colors && ap.colors.skin;
@@ -292,8 +384,16 @@
var margin = 28;
var originX = margin + scene.rows * this.tileW / 2 + this.tileW;
var originY = margin + this.imageH;
- this.canvas.width = Math.ceil((scene.cols + scene.rows) * this.tileW / 2 + margin * 2 + this.tileW * 2);
- this.canvas.height = Math.ceil((scene.cols + scene.rows) * this.tileH / 2 + this.imageH + margin * 2);
+ var wantW = Math.ceil((scene.cols + scene.rows) * this.tileW / 2 + margin * 2 + this.tileW * 2);
+ var wantH = Math.ceil((scene.cols + scene.rows) * this.tileH / 2 + this.imageH + margin * 2);
+ // Only ASSIGN width/height when they actually change. Assigning either one reallocates and
+ // clears the whole backing buffer, and this renderer re-runs every animation frame (the
+ // walkable pulse and the flames keep `_animating` set) — so the old unconditional assignment
+ // was throwing away and re-allocating a multi-megapixel canvas 60 times a second.
+ if (this.canvas.width !== wantW || this.canvas.height !== wantH) {
+ this.canvas.width = wantW;
+ this.canvas.height = wantH;
+ }
var ctx = this.ctx;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
@@ -333,6 +433,9 @@
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
var now = Date.now();
+ // Light emitters collected while drawing, applied additively in one pass afterwards so the
+ // pools land on the floor, the props AND the characters standing in them.
+ var lights = [];
for (i = 0; i < items.length; i++) {
var it = items[i];
if (it.type === 'tile') {
@@ -353,13 +456,28 @@
// Fog fade: dim explored-but-distant tiles toward the (near-black) background so the
// remembered area melts into the dark with distance — parity with the 2D modes.
var lb = (scene.lightGrid && scene.lightGrid[it.y] && scene.lightGrid[it.y][it.x] != null) ? scene.lightGrid[it.y][it.x] : 1;
- ctx.globalAlpha = cut ? 0.28 : Math.max(0.16, lb);
- if (rec && rec.ready) this._drawImage(rec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
- else this._diamond(it.sx, it.sy + this.tileH * 0.5, (legend[it.kind] && legend[it.kind].color) || '#3a4048');
+ // Cutaway walls stay ALPHA-faded (you need to see the floor through them). Everything
+ // else is shaded opaque, so distant stone is dark stone rather than a ghost.
+ ctx.globalAlpha = cut ? 0.28 : 1;
+ if (rec && rec.ready) {
+ var img = cut ? rec.img : this._shadedImage(rec.img, Math.max(0.16, lb));
+ this._drawImage(img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
+ } else {
+ ctx.globalAlpha = cut ? 0.28 : Math.max(0.16, lb);
+ this._diamond(it.sx, it.sy + this.tileH * 0.5, (legend[it.kind] && legend[it.kind].color) || '#3a4048');
+ }
// Walkable-floor pip: mark every visible floor cell with a soft dot so the WALKABLE
// ground is explicit everywhere. A dark gap with no pip is unambiguously NOT floor —
// that's what stops iso wall-gaps/voids from reading as holes you can walk into.
if (it.kind === 'floor' || it.kind === 'floor2') {
+ // Crevice shadow where this floor cell abuts stone. Cheap AO — the more walls
+ // around it, the deeper the cell sits in shadow.
+ var adj = (this._isWall(scene, it.x - 1, it.y) ? 1 : 0) + (this._isWall(scene, it.x + 1, it.y) ? 1 : 0) +
+ (this._isWall(scene, it.x, it.y - 1) ? 1 : 0) + (this._isWall(scene, it.x, it.y + 1) ? 1 : 0);
+ if (adj) {
+ ctx.globalAlpha = Math.max(0.14, lb);
+ this._wallAO(it.sx, it.sy + this.tileH * 0.5, Math.min(0.42, 0.14 * adj));
+ }
ctx.globalAlpha = Math.max(0.14, lb) * 0.5;
ctx.fillStyle = 'rgba(150,215,255,1)';
ctx.beginPath();
@@ -384,6 +502,15 @@
} else {
var fscale = this.tileW * (it.def.fxScale || 0.34) * 0.7;
RK.fx.fire(ctx, fcx, fgy, fscale, now, (it.x * 7 + it.y * 13) % 97);
+ var seed = (it.x * 7 + it.y * 13) % 97;
+ this._embers(fcx, fgy - this.tileH * 0.2, seed, now);
+ // Torch flames flicker, so the pool they cast has to flicker with them.
+ var flick = 0.82 + Math.sin(now / 95 + seed) * 0.12 + Math.sin(now / 41 + seed * 2) * 0.06;
+ lights.push({
+ x: fcx, y: it.sy + this.tileH, r: this.tileW * 1.75 * flick,
+ inner: 'rgba(255,168,74,' + (0.15 * flick).toFixed(3) + ')',
+ outer: 'rgba(196,96,26,' + (0.045 * flick).toFixed(3) + ')'
+ });
}
} else if (it.type === 'entity') {
// Dungeon FEATURES/ITEMS (entrance/exit/treasure/items) are tiles/glyphs, NOT avatars.
@@ -395,6 +522,23 @@
var frec = furl && this._load(furl);
if (frec && frec.ready) this._drawImage(frec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
else this._glyph(it.sx, it.sy + this.tileH * 0.4, it.e.char || '?', it.e.color || '#fff');
+ // Objectives beacon. Stairs and treasure are the only two things in the dungeon
+ // worth crossing a room for, so they get a slow breathing glow in their own
+ // colour — findable from across a dark hall without leaking the layout.
+ var beaconRGB = it.e.char === '<' ? '63,185,80'
+ : it.e.char === '>' ? '210,153,34'
+ : it.e.char && it.e.char.charAt(0) === '$' ? '251,191,36' : null;
+ if (beaconRGB) {
+ this._animating = true;
+ // NB: entity items carry the cell on `it.e`, not `it.x` (that is only set on
+ // tile items) — reading it.x here produced a NaN radius and threw.
+ var breathe = 0.55 + Math.sin(now / 620 + it.e.x) * 0.45;
+ lights.push({
+ x: it.sx, y: it.sy + this.tileH, r: this.tileW * (1.1 + breathe * 0.35),
+ inner: 'rgba(' + beaconRGB + ',' + (0.10 + breathe * 0.12).toFixed(3) + ')',
+ outer: 'rgba(' + beaconRGB + ',' + (0.03 + breathe * 0.04).toFixed(3) + ')'
+ });
+ }
continue;
}
this._contactShadow(it.sx, it.sy + this.tileH * 1.15, 18, 7);
@@ -446,10 +590,59 @@
this._animating = true; // keep the pulse animating between game updates
}
+ // The player carries a lantern. Its pool is drawn last of the emitters so it always wins in
+ // the cell you actually occupy, and it breathes slightly so a still player isn't a static image.
+ if (this.focusPoint) {
+ var lantern = 0.9 + Math.sin(now / 170) * 0.07 + Math.sin(now / 73) * 0.03;
+ lights.push({
+ x: this.focusPoint.x, y: this.focusPoint.y, r: this.tileW * 2.7 * lantern,
+ inner: 'rgba(255,214,158,' + (0.115 * lantern).toFixed(3) + ')',
+ outer: 'rgba(210,140,70,' + (0.04 * lantern).toFixed(3) + ')'
+ });
+ this._animating = true;
+ }
+ for (var lg = 0; lg < lights.length; lg++) {
+ var L = lights[lg];
+ this._lightPool(L.x, L.y, L.r, L.inner, L.outer);
+ }
+ // Colour-temperature separation: everything the player can currently SEE is warm torchlight;
+ // the explored-but-remembered parts get a cold blue wash. Alpha alone made memory read as
+ // "far away"; the temperature shift makes it read as "remembered", which is what it is.
+ this._memoryWash(scene, originX, originY);
+
this._vignette();
if (this._animating) this._invalidate();
};
+ // Cold blue overlay on explored-but-not-currently-visible cells.
+ IsoRenderer.prototype._memoryWash = function (scene, originX, originY) {
+ var light = scene.lightGrid;
+ if (!light) return;
+ var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
+ ctx.save();
+ ctx.globalCompositeOperation = 'source-atop';
+ // A DARK blue. A mid-tone blue at 20% actually lifted the already-darkened memory tiles
+ // brighter than the torchlit centre, inverting the whole depth hierarchy — remembered rooms
+ // glowed and the room you were standing in receded.
+ ctx.fillStyle = 'rgba(16,28,52,1)';
+ for (var y = 0; y < scene.rows; y++) {
+ for (var x = 0; x < scene.cols; x++) {
+ var kind = scene.grid[y] && scene.grid[y][x];
+ if (!kind || kind === 'dark') continue;
+ var lb = (light[y] && light[y][x] != null) ? light[y][x] : 1;
+ if (lb >= 0.3) continue; // currently lit — leave it warm
+ var p = this._project(x, y, originX, originY), cy = p.y + hh;
+ ctx.globalAlpha = 0.45 * (1 - lb / 0.3);
+ ctx.beginPath();
+ ctx.moveTo(p.x, cy - hh); ctx.lineTo(p.x + hw, cy);
+ ctx.lineTo(p.x, cy + hh); ctx.lineTo(p.x - hw, cy);
+ ctx.closePath();
+ ctx.fill();
+ }
+ }
+ ctx.restore();
+ };
+
IsoRenderer.prototype.destroy = function () {
if (this._raf) cancelAnimationFrame(this._raf);
if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
@@ -457,6 +650,7 @@
this.ctx = null;
this.cache = {};
this.tintCache = {};
+ this.shadeCache = {};
this.boundsCache = {};
this.last = {};
this.lastScene = null;
diff --git a/html/js/render/threeRenderer.js b/html/js/render/threeRenderer.js
index 953bff5..c9a8056 100644
--- a/html/js/render/threeRenderer.js
+++ b/html/js/render/threeRenderer.js
@@ -1,5 +1,10 @@
-// ThreeRenderer — lightweight 3D projection for the shared Scene. It uses generated GLB avatars
+// 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';
@@ -11,6 +16,115 @@
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');
@@ -26,7 +140,8 @@
this.zoom = 1;
this.panX = 0;
this.panY = 0;
- this._sizeKey = null;
+ this.buckets = {};
+ this.torchCells = [];
this._destroyed = false;
this._init();
}
@@ -38,10 +153,12 @@
// 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.
- // (camera sits ~14.5 units from the player; fade from there out to ~15 tiles.)
- this.scene.fog = new T.Fog(0x000000, 14, 32);
- this.camera = new T.OrthographicCamera(-10, 10, 10, -10, 0.1, 100);
- this.camera.position.set(8, 9, 8);
+ // 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';
@@ -50,22 +167,57 @@
if (T.SRGBColorSpace) this.renderer.outputColorSpace = T.SRGBColorSpace;
if (T.ACESFilmicToneMapping != null) {
this.renderer.toneMapping = T.ACESFilmicToneMapping;
- this.renderer.toneMappingExposure = 1.08;
+ 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);
- var hemi = new T.HemisphereLight(0xfff3d2, 0x283044, 2.1);
+ 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);
- var key = new T.DirectionalLight(0xffffff, 1.6);
- key.position.set(6, 10, 5);
- this.scene.add(key);
- // A subtle 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, 2.2, 8, 2);
- this.playerLight.position.set(0, 3.2, 0);
+ 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() {
@@ -75,6 +227,61 @@
});
};
+ 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).
@@ -85,7 +292,7 @@
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 = 8 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
+ 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;
@@ -118,57 +325,305 @@
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) mat.dispose(); });
+ 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;
- // Visibility changes rebuild the floor/walls, but live entities must stay attached to their
- // stable layer. Clearing the whole world here made every avatar disappear after the first
- // fog-of-war update because this.entities still pointed at detached objects.
- if (this.tileLayer) {
- this.world.remove(this.tileLayer);
- this._disposeObject(this.tileLayer);
- }
- this.tileLayer = new T.Group();
- this.world.add(this.tileLayer);
- if (!this.entityLayer) {
- this.entityLayer = new T.Group();
- this.world.add(this.entityLayer);
+ 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;
- for (var y = 0; y < scene.rows; y++) {
- for (var x = 0; x < scene.cols; x++) {
- var kind = scene.grid[y][x];
- if (kind === 'dark') continue; // unexplored — leave it void (fog-of-war: never render it)
- var def = scene.legend[kind] || {};
- var h = kind === 'wall' ? 0.9 : (kind === 'bar' ? 0.42 : (kind === 'table' ? 0.28 : 0.08));
- var geom = new T.BoxGeometry(kind === 'wall' ? 1 : 0.96, h, kind === 'wall' ? 1 : 0.96);
- var mesh = new T.Mesh(geom, this._mat(kind === 'floor' ? '#4f4a3e' : (def.color || '#333333')));
- mesh.position.set(x - cx, h / 2 - 0.05, y - cz);
- this.tileLayer.add(mesh);
+ 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.world.position.set(0, 0, 0);
+
+ 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.44, 1.18, 4, 12), this._mat(tintForVisual(visual, e.color || '#9aa4b2')));
- body.position.y = 1.0;
+ 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.32, 0.44);
+ 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;
@@ -176,6 +631,7 @@
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;
@@ -256,10 +712,11 @@
return null;
};
- ThreeRenderer.prototype._makeEntity = function (e) {
+ 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) {
@@ -293,16 +750,35 @@
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();
- var key = scene.cols + 'x' + scene.rows + ':' + scene.grid.map(function (r) { return r.join(''); }).join('/');
- if (key !== this._sizeKey) {
- this._buildTiles(scene);
- this._sizeKey = key;
+
+ // 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 (var i = 0; i < scene.entities.length; i++) {
+ for (i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i], id = e.id;
seen[id] = true;
var ent = this.entities[id];
@@ -331,6 +807,32 @@
}
};
+ // 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 {
@@ -344,9 +846,18 @@
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;
}
@@ -364,8 +875,8 @@
o.rotation.y += (r - o.rotation.y) * 0.18;
}
}
- // Player-follow camera: keep the fixed iso offset (8,9,8) but re-target the player's world
- // position each frame. The player mesh itself lerps, so the camera glides smoothly with it.
+ // 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) {
@@ -378,13 +889,28 @@
// 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 = (16 / (this.zoom || 1)) / Math.max(1, viewportHeight);
+ 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 + 8, 9, targetZ + 8);
+ 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, 3.2, pcam.position.z);
+ 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) {
@@ -400,6 +926,18 @@
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();
@@ -409,6 +947,7 @@
this.entities = {};
this.models = {};
this.mixers = [];
+ this.torchCells = [];
};
root.RK = root.RK || {};
diff --git a/html/tavern.html b/html/tavern.html
index 65f986d..2f75333 100644
--- a/html/tavern.html
+++ b/html/tavern.html
@@ -27,6 +27,7 @@
+