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'); + }); +});