diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 0805880..8992fd0 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -275,6 +275,41 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por | `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38083 | | `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 | +### Daemon redundancy and graceful degradation + +`RPC_ENDPOINTS` takes any number of daemons, comma or whitespace separated, in **preference order**: + +``` +RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568,http://10.42.1.41:34568 +``` + +`PRIMARY_RPC_ENDPOINT` / `FALLBACK_RPC_ENDPOINT` still work and are appended to the list. The list +is deduplicated — note that the stock configuration sets primary and fallback to the *same* host, so +by default there is exactly one node and no redundancy at all. Add at least one genuinely separate +daemon before relying on failover. + +Behaviour: + +- one logical RPC call tries **every** configured node before it fails; +- each node must pass the same chain-identity check before it may answer, so failover can never + silently serve a different chain's data; +- after failing over, the preferred node is re-tested every `RPC_PREFERRED_RETRY_MS` (default 60s, + minimum 1s), so recovery does not need a restart; +- when **no** node answers, the service reports unhealthy and throws. `getBlockCountStrict()` — + used by fairness and match seeding — never substitutes a cached height. + +**Entries are refused while no node is reachable.** A run's lifetime is counted in blocks and its +entry block comes from the last successful poll; admitting a game during an outage anchors it to an +already-past block and it is killed as a timeout on the first recovered poll. The player is told +`Can't reach a node right now…` and nothing is charged. Credits are not consumed, because +the gate runs before any spend. + +**The wallet is deliberately NOT failed over.** Two `wallet-rpc` processes serving one wallet file +can build transactions from the same outputs and corrupt the wallet cache — a standby wallet is a +custody decision for the operator, not something the app may do on its own. Daemons are read-only +and interchangeable; wallets are neither. When the wallet is unreachable the server declines paid +intake with `Payments are temporarily unavailable…`, and credits and free play keep working. + ### Separate systemd Service Install the hash-pinned fleet copy of `monerogue.service`; it is intentionally absent from the 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 @@ +