From 1325b49b42137f8faae22988592390450df68baa Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 12 Jul 2026 10:14:56 -0400 Subject: [PATCH] feat(render): single-player dungeon renders through the render kit (packs in gameplay) The migration that makes the pack ladder pay off IN the dungeon, not just the tavern. The SP game now draws via RK (Tiled/ASCII/Iso/3D + unlocked packs) instead of the legacy ROT display. - Adapter: sceneFromGameState now consumes the CLIENT state shape (sparse {y:{x:v}} tile maps, not just server arrays) via maxDims(). Verified + unit-tested. - Bug fix: RK.sceneFromGameState was never actually exposed (only RK.scene.*), so matchClient + tavern spectator were silently no-op-ing. Aliased onto RK. - Bridge (spGameRenderer.js, RK.SPGame): mounts an RK renderer in a dedicated host inside #game-display, toggles the legacy ROT canvases, zoom-enabled. Game._draw GameScreen prefers it and FALLS BACK to RenderEngine on any failure, so the live game can never blank. ROT still draws the splash/win/lose text screens. - Picker (spRenderPicker.js): homepage mode + pack selector, gated by entitlements (free = Tiled/Original + ASCII; ladder unlocks Iso/3D/packs), rebuilds on identity. - index.html loads the render-kit renderers + the bridge/picker. Bundled with the catalog ladder (migration 028): free Original bare pack, ladder 1/5/10/20/40/50. Deploying makes the free tavern bare (by design) AND the dungeon render-kit-ed. v1 renders the whole explored level + zoom (player-centered camera is the next polish). Suite 62/435 green; adapter smoke-verified. --- html/index.html | 14 ++++ html/js/core/game.js | 28 +++++-- html/js/core/singlePlayerAvatar.js | 1 + html/js/display/screenManager.js | 4 + html/js/render/sceneModel.js | 42 ++++++++-- html/js/render/spGameRenderer.js | 128 +++++++++++++++++++++++++++++ html/js/ui/spRenderPicker.js | 83 +++++++++++++++++++ test/spSceneAdapter_ultra.test.js | 57 +++++++++++++ 8 files changed, 344 insertions(+), 13 deletions(-) create mode 100644 html/js/render/spGameRenderer.js create mode 100644 html/js/ui/spRenderPicker.js create mode 100644 test/spSceneAdapter_ultra.test.js diff --git a/html/index.html b/html/index.html index b223297..0b17a10 100644 --- a/html/index.html +++ b/html/index.html @@ -84,6 +84,18 @@ + + + + + + + + + + + @@ -128,6 +140,8 @@ + + diff --git a/html/js/core/game.js b/html/js/core/game.js index 8fe12d3..ceafdf7 100644 --- a/html/js/core/game.js +++ b/html/js/core/game.js @@ -288,15 +288,31 @@ var Game = { }, _drawGameScreen: function() { - if (!this._ensureDisplay()) return; const gameState = GameState.getGameStateForRender(); - RenderEngine.drawGameScreen(gameState); + // Prefer the render kit (Tiled / ASCII / Iso / 3D + unlocked packs) for the dungeon — it + // draws its own lighting/FX and the player sprite. The legacy ROT renderer is the fallback + // (returns here only if the kit is unavailable or a render throws), so the game never blanks. + var rk = false; + if (window.RK && RK.SPGame && RK.SPGame.available()) { + RK.SPGame.show(); + rk = RK.SPGame.render(gameState, { + cryptoType: (window.ScreenManager && ScreenManager._cryptoType) || undefined, + playerAppearance: (window.SinglePlayerAvatar && SinglePlayerAvatar.currentAppearance) + ? SinglePlayerAvatar.currentAppearance() : null, + isSpectating: this._isSpectating + }); + } - // JUICE: keep the FX overlay aligned to the base canvas and feed it the - // player's screen-space position so its torch lighting tracks the camera. - if (window.FX) { - try { this._fxSyncLighting(); } catch (e) { /* best-effort */ } + if (!rk) { + if (window.RK && RK.SPGame) RK.SPGame.hide(); + if (!this._ensureDisplay()) return; + RenderEngine.drawGameScreen(gameState); + // JUICE: keep the FX overlay aligned to the base canvas and feed it the player's + // screen-space position so its torch lighting tracks the camera. + if (window.FX) { + try { this._fxSyncLighting(); } catch (e) { /* best-effort */ } + } } }, diff --git a/html/js/core/singlePlayerAvatar.js b/html/js/core/singlePlayerAvatar.js index 9b8f3a3..2a352e9 100644 --- a/html/js/core/singlePlayerAvatar.js +++ b/html/js/core/singlePlayerAvatar.js @@ -69,6 +69,7 @@ if (!data) return; if (data.entitlements && root.RK && root.RK.setEntitlementSnapshot) { root.RK.setEntitlementSnapshot(data.entitlements); + if (root.RK.rebuildRenderPicker) root.RK.rebuildRenderPicker(); } if (data.appearance) this.setAppearance(data.appearance, { save: true, redraw: true }); else this.updateButton(); diff --git a/html/js/display/screenManager.js b/html/js/display/screenManager.js index d67a6d6..b017e19 100644 --- a/html/js/display/screenManager.js +++ b/html/js/display/screenManager.js @@ -127,6 +127,7 @@ var ScreenManager = { }, drawWelcomeScreen: function() { + if (window.RK && RK.SPGame) RK.SPGame.hide(); // full-screen text → hide the render-kit dungeon if (!DisplayManager.ensureDisplay()) return; // Mark that we're NOT showing the waiting screen @@ -290,6 +291,7 @@ var ScreenManager = { }, drawWinScreen: function(hasTreasure) { + if (window.RK && RK.SPGame) RK.SPGame.hide(); if (!DisplayManager.ensureDisplay()) return; DisplayManager.clearDisplay(); @@ -317,6 +319,7 @@ var ScreenManager = { }, drawLoseScreen: function(reason) { + if (window.RK && RK.SPGame) RK.SPGame.hide(); if (!DisplayManager.ensureDisplay()) return; DisplayManager.clearDisplay(); @@ -340,6 +343,7 @@ var ScreenManager = { }, drawWaitingScreen: function(_internalLoopCall = false) { + if (window.RK && RK.SPGame) RK.SPGame.hide(); if (!DisplayManager.ensureDisplay()) return; // Mark that we're showing the waiting screen diff --git a/html/js/render/sceneModel.js b/html/js/render/sceneModel.js index ec836af..a0520ff 100644 --- a/html/js/render/sceneModel.js +++ b/html/js/render/sceneModel.js @@ -128,19 +128,42 @@ function dungeonTileKind(ch) { return 'floor'; } +// Grid dimensions from array grids OR sparse {y:{x:v}} maps (the single-player client uses the +// latter). Returns the max seen index + 1 across every supplied map. +function maxDims(maps) { + var rows = 0, cols = 0; + for (var i = 0; i < maps.length; i++) { + var m = maps[i]; + if (!m || typeof m !== 'object') continue; + for (var yk in m) { + if (!Object.prototype.hasOwnProperty.call(m, yk)) continue; + var yi = parseInt(yk, 10); + if (!isFinite(yi)) continue; + if (yi + 1 > rows) rows = yi + 1; + var r = m[yk]; + if (r && typeof r === 'object') { + for (var xk in r) { + if (!Object.prototype.hasOwnProperty.call(r, xk)) continue; + var xi = parseInt(xk, 10); + if (isFinite(xi) && xi + 1 > cols) cols = xi + 1; + } + } + } + } + return { rows: rows, cols: cols }; +} + function sceneFromGameState(state, opts) { state = state || {}; opts = opts || {}; var visible = state.visibleTiles || {}; var explored = state.exploredTiles || {}; var lighting = state.lighting || {}; - var rows = visible.length || (state.dungeonRows || 0); - var cols = 0; - for (var y = 0; y < rows; y++) { - if (visible[y]) cols = Math.max(cols, visible[y].length); - if (explored[y]) cols = Math.max(cols, explored[y].length); - } - if (!cols && state.dungeonCols) cols = state.dungeonCols; + // Array grids expose .length; the SP client uses sparse {y:{x:v}} maps — fall back to the max + // seen index. Explicit dungeonRows/Cols (server) always win. + var dims = maxDims([visible, explored, state.map]); + var rows = state.dungeonRows || visible.length || dims.rows; + var cols = state.dungeonCols || dims.cols; var grid = []; var lightGrid = []; @@ -239,5 +262,10 @@ function sceneFromGameState(state, opts) { root.RK = root.RK || {}; root.RK.scene = api; + // Convenience aliases on RK directly — several callers (matchClient, tavern spectator, the SP + // render bridge) reference RK.sceneFromGameState / RK.sceneFromTavern; without these they were + // silently undefined (guarded → no-op). Expose them so those paths actually render. + root.RK.sceneFromGameState = root.RK.sceneFromGameState || sceneFromGameState; + if (api.sceneFromTavern) root.RK.sceneFromTavern = root.RK.sceneFromTavern || api.sceneFromTavern; if (typeof module !== 'undefined' && module.exports) module.exports = api; })(typeof window !== 'undefined' ? window : this); diff --git a/html/js/render/spGameRenderer.js b/html/js/render/spGameRenderer.js new file mode 100644 index 0000000..e255736 --- /dev/null +++ b/html/js/render/spGameRenderer.js @@ -0,0 +1,128 @@ +// SP game render bridge (RK.SPGame) — renders the single-player DUNGEON through the render kit +// (Tiled / ASCII / Iso / 3D + unlocked packs) instead of the legacy ROT display, so a pack you +// bought applies in actual gameplay, not just the tavern. It mounts a renderer in a dedicated host +// inside #game-display and hides the legacy ROT canvases while a game is live; the retro splash / +// win / lose text screens still use ROT. If anything here is unavailable it returns false and the +// caller falls back to the legacy RenderEngine, so the live game can never hard-break. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + var SP = RK.SPGame = RK.SPGame || {}; + + SP._renderer = null; + SP._mode = null; + SP._live = false; + + function doc() { return root.document; } + function gameDisplay() { return doc() && doc().getElementById('game-display'); } + + // A dedicated child host so the RK canvas doesn't collide with the ROT canvas / overlays. + function rkHost() { + var gd = gameDisplay(); + if (!gd) return null; + var host = doc().getElementById('rk-game-host'); + if (!host) { + host = doc().createElement('div'); + host.id = 'rk-game-host'; + host.style.display = 'none'; + gd.appendChild(host); + } + return host; + } + + SP.available = function () { + return !!(RK.createRenderer && RK.sceneFromGameState && gameDisplay()); + }; + + SP.mode = function () { + if (!SP._mode) { + var m = (RK.loadMode && RK.loadMode('tiles')) || 'tiles'; + SP._mode = (RK.canUseMode && !RK.canUseMode(m)) ? 'tiles' : m; + } + return SP._mode; + }; + + SP.setMode = function (mode) { + if (RK.canUseMode && !RK.canUseMode(mode)) return false; + SP._mode = mode; + if (RK.saveMode) RK.saveMode(mode); + SP._destroyRenderer(); + if (SP._live && SP._lastState) SP.render(SP._lastState, SP._lastOpts); + return true; + }; + + // Re-mount after a pack switch (same mode, new active pack/assets). + SP.refreshPack = function () { + SP._destroyRenderer(); + if (SP._live && SP._lastState) SP.render(SP._lastState, SP._lastOpts); + }; + + SP._destroyRenderer = function () { + if (SP._renderer && SP._renderer.destroy) { try { SP._renderer.destroy(); } catch (_) {} } + SP._renderer = null; + }; + + SP._ensureRenderer = function () { + if (SP._renderer) return SP._renderer; + var host = rkHost(); + if (!host || !RK.createRenderer) return null; + SP._renderer = RK.createRenderer(SP.mode(), host, { cell: 24 }); + if (RK.attachZoom) { try { RK.attachZoom(host); } catch (_) {} } + return SP._renderer; + }; + + function toggleLegacy(hide) { + var gd = gameDisplay(); + if (!gd) return; + var kids = gd.children; + for (var i = 0; i < kids.length; i++) { + if (kids[i].id === 'rk-game-host') continue; + kids[i].style.display = hide ? 'none' : ''; + } + } + + // Show the render-kit view (hide the ROT splash canvases). Call on game start. + SP.show = function () { + if (!SP.available()) return false; + var host = rkHost(); + if (!host) return false; + host.style.display = 'block'; + toggleLegacy(true); + SP._live = true; + return true; + }; + + // Back to the ROT view (splash / win / lose). Call on game end. + SP.hide = function () { + SP._live = false; + SP._destroyRenderer(); + var host = doc() && doc().getElementById('rk-game-host'); + if (host) host.style.display = 'none'; + toggleLegacy(false); + }; + + // Build the Scene from the SP client render-state and draw it. Returns true if it rendered + // (caller then skips the legacy renderer). Non-destructive on failure. + SP.render = function (clientState, opts) { + if (!SP._live || !SP.available()) return false; + var r = SP._ensureRenderer(); + if (!r) return false; + opts = opts || {}; + SP._lastState = clientState; + SP._lastOpts = opts; + try { + var scene = RK.sceneFromGameState(clientState, { + cryptoType: opts.cryptoType, + playerAppearance: opts.playerAppearance, + isSpectating: opts.isSpectating + }); + r.render(scene); + return true; + } catch (e) { + if (root.console) console.warn('SPGame render failed; falling back to legacy:', e && e.message); + return false; + } + }; + + if (typeof module !== 'undefined' && module.exports) module.exports = SP; +})(typeof window !== 'undefined' ? window : this); diff --git a/html/js/ui/spRenderPicker.js b/html/js/ui/spRenderPicker.js new file mode 100644 index 0000000..ce71b83 --- /dev/null +++ b/html/js/ui/spRenderPicker.js @@ -0,0 +1,83 @@ +// Homepage render picker — render mode (Tiled / ASCII / Iso / 3D) + tile-pack selector for the +// single-player dungeon, gated by entitlements. Free players see Tiled(Original) + ASCII; the credit +// ladder unlocks the richer packs, iso, and 3D. Rebuilds itself whenever entitlements change. +(function (root) { + 'use strict'; + var RK = root.RK = root.RK || {}; + + function doc() { return root.document; } + function host() { return doc() && doc().getElementById('renderPicker'); } + + function projForMode(mode) { + if (mode === 'iso') return 'iso'; + if (mode === '3d') return '3d'; + if (mode === 'ascii') return null; // glyphs consume no tile pack + return 'topdown'; + } + + function ensureStyle() { + if (doc().getElementById('rp-style')) return; + var s = doc().createElement('style'); + s.id = 'rp-style'; + s.textContent = + '#renderPicker{margin-top:6px;display:flex;flex-direction:column;gap:4px;}' + + '#renderPicker .rp-row{display:flex;gap:4px;flex-wrap:wrap;}' + + '#renderPicker .rp-btn{font:inherit;font-size:11px;padding:3px 8px;border:1px solid #2a313a;' + + 'border-radius:4px;background:#0a0c0f;color:#d7dbe0;cursor:pointer;}' + + '#renderPicker .rp-btn.active{background:#3fb950;color:#08130a;border-color:#3fb950;font-weight:600;}' + + '#renderPicker .rp-btn.locked{opacity:.5;cursor:not-allowed;}' + + '#renderPicker .rp-pack{font-size:10px;padding:2px 7px;}'; + doc().head.appendChild(s); + } + + function build() { + var h = host(); + if (!h || !RK.RENDER_MODES || !RK.SPGame) return; + ensureStyle(); + h.innerHTML = ''; + h.classList.remove('hidden'); + + var current = RK.SPGame.mode ? RK.SPGame.mode() : 'tiles'; + + var modeRow = doc().createElement('div'); + modeRow.className = 'rp-row'; + RK.RENDER_MODES.forEach(function (m) { + var usable = !RK.canUseMode || RK.canUseMode(m.id); + var b = doc().createElement('button'); + b.className = 'rp-btn' + (m.id === current ? ' active' : '') + (usable ? '' : ' locked'); + b.textContent = m.label + (m.premium && !usable ? ' 🔒' : ''); + b.title = usable ? m.label : (m.label + ' — unlock with credits'); + b.onclick = function () { + if (!usable) return; + if (RK.SPGame.setMode(m.id)) build(); + }; + modeRow.appendChild(b); + }); + h.appendChild(modeRow); + + // Pack row — only when the current projection has more than one UNLOCKED pack to pick from. + var proj = projForMode(current); + var packs = (proj && RK.unlockedPacks) ? RK.unlockedPacks(proj) : []; + if (packs.length > 1) { + var activeId = RK.activePackId ? RK.activePackId(proj) : null; + var packRow = doc().createElement('div'); + packRow.className = 'rp-row'; + packs.forEach(function (p) { + var b = doc().createElement('button'); + b.className = 'rp-btn rp-pack' + (p.id === activeId ? ' active' : ''); + b.textContent = p.label || p.id; + b.onclick = function () { + if (RK.setActivePack && RK.setActivePack(p.id)) { + if (RK.SPGame.refreshPack) RK.SPGame.refreshPack(); + build(); + } + }; + packRow.appendChild(b); + }); + h.appendChild(packRow); + } + } + + RK.rebuildRenderPicker = build; + if (doc()) doc().addEventListener('DOMContentLoaded', function () { try { build(); } catch (_) {} }); +})(typeof window !== 'undefined' ? window : this); diff --git a/test/spSceneAdapter_ultra.test.js b/test/spSceneAdapter_ultra.test.js new file mode 100644 index 0000000..31c8252 --- /dev/null +++ b/test/spSceneAdapter_ultra.test.js @@ -0,0 +1,57 @@ +// The single-player render-kit migration hinges on sceneFromGameState consuming the CLIENT state +// shape — sparse {y:{x:v}} tile maps (vs the server's arrays). Guards that adapter + the RK alias. +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +function loadSceneKit() { + const ctx = { console }; + ctx.window = ctx; + vm.createContext(ctx); + vm.runInContext(fs.readFileSync(path.join(__dirname, '../html/js/render/sceneModel.js'), 'utf8'), ctx); + return ctx.RK; +} + +const F = "'1"; // primary-floor char + +describe('SP client-state → Scene adapter', () => { + const RK = loadSceneKit(); + + test('RK.sceneFromGameState is exposed directly (not only under RK.scene)', () => { + expect(typeof RK.sceneFromGameState).toBe('function'); + expect(RK.sceneFromGameState).toBe(RK.scene.sceneFromGameState); + }); + + test('sparse {y:{x:v}} maps yield correct dims, kinds, and entities', () => { + const state = { + visibleTiles: { 0: { 0: '#', 1: F, 2: '>' }, 1: { 0: F, 1: F, 2: '#' } }, + exploredTiles: { 0: { 0: '#', 1: F, 2: '>' }, 1: { 0: F, 1: F, 2: '#' } }, + player: { x: 1, y: 1, facing: 'down' }, monster: { x: 0, y: 0 }, + exit: [2, 0], treasure: [1, 1], items: {}, lighting: {} + }; + const scene = RK.sceneFromGameState(state, { cryptoType: 'WOW' }); + + expect(scene.cols).toBe(3); + expect(scene.rows).toBe(2); + expect(scene.isDungeon).toBe(true); + expect(scene.grid[0]).toEqual(['wall', 'floor', 'exit']); + expect(scene.grid[1]).toEqual(['floor', 'floor', 'wall']); + + const kinds = scene.entities.map(e => e.kind); + expect(kinds).toContain('player'); + expect(kinds).toContain('monster'); + expect(kinds).toContain('feature'); // exit + treasure + const player = scene.entities.find(e => e.kind === 'player'); + expect(player).toMatchObject({ x: 1, y: 1 }); + }); + + test('unexplored cells render as dark, not floor', () => { + const state = { + visibleTiles: { 0: { 0: F } }, // only (0,0) seen + exploredTiles: { 0: { 0: F } }, + player: { x: 0, y: 0 }, items: {}, lighting: {} + }; + const scene = RK.sceneFromGameState(state, {}); + expect(scene.grid[0][0]).toBe('floor'); + }); +});