From dacbf356ede99c9a2ad6f12ef9b187a26070121f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 26 Jul 2026 07:29:47 -0400 Subject: [PATCH 1/3] fix: close pre-launch review findings and light the premium renderers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/RENDER_PACKS.md | 45 +- docs/TAVERN_AND_MULTIPLAYER.md | 27 + html/js/input/inputHandler.js | 33 +- html/js/network/socketHandlers.js | 37 +- html/js/render/chainAmbience.js | 217 +++++++ html/js/render/isoRenderer.js | 210 ++++++- html/js/render/threeRenderer.js | 647 +++++++++++++++++++-- html/tavern.html | 45 +- src/config/environmentValidator.js | 21 + src/debug/debugManager.js | 33 +- src/game/game.js | 40 +- src/game/gameModeManager.js | 61 +- src/game/movementManager.js | 11 + src/network/addressManager.js | 10 + src/network/broadcastManager.js | 16 +- src/network/connectionHandler.js | 22 +- src/network/rateLimiter.js | 21 +- src/network/socketHandlers.js | 174 ++++-- src/network/spectatorManager.js | 13 +- src/network/tavernManager.js | 77 ++- src/payments/walletRPCService.js | 54 +- src/routes/auth.js | 26 + src/rpc/rpcService.js | 24 + src/scripts/stagenet-financial-canary.js | 43 +- test/batchPayout.test.js | 47 ++ test/rateLimiterKeyResolution.test.js | 65 +++ test/serverSideFogOfWar.test.js | 99 ++++ test/smirkNip98_ultra.test.js | 35 ++ test/socketHandlerFaultContainment.test.js | 134 +++++ test/tavernManager.test.js | 14 +- 30 files changed, 2117 insertions(+), 184 deletions(-) create mode 100644 html/js/render/chainAmbience.js create mode 100644 test/rateLimiterKeyResolution.test.js create mode 100644 test/serverSideFogOfWar.test.js create mode 100644 test/socketHandlerFaultContainment.test.js 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 @@ +