mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
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.
This commit is contained in:
@@ -84,6 +84,18 @@
|
||||
<script src="js/render/renderModes.js"></script>
|
||||
<script src="js/render/charCustomize.js"></script>
|
||||
<script src="js/render/avatarVisuals.js"></script>
|
||||
<!-- Render-kit renderers so the single-player DUNGEON draws through the kit (Tiled/ASCII/Iso/3D
|
||||
+ unlocked packs), not the legacy ROT display. Loaded before spGameRenderer.js. -->
|
||||
<script src="js/render/sceneModel.js"></script>
|
||||
<script src="js/render/fxLayer.js"></script>
|
||||
<script src="js/render/catSprites.js"></script>
|
||||
<script src="js/render/asciiRenderer.js"></script>
|
||||
<script src="js/render/tileRenderer.js"></script>
|
||||
<script src="js/render/isoRenderer.js"></script>
|
||||
<script src="js/render/threeRenderer.js"></script>
|
||||
<script src="js/render/zoomControl.js"></script>
|
||||
<script src="js/render/spGameRenderer.js"></script>
|
||||
<script src="js/ui/spRenderPicker.js"></script>
|
||||
<script src="js/core/singlePlayerAvatar.js"></script>
|
||||
|
||||
<!-- Other modules -->
|
||||
@@ -128,6 +140,8 @@
|
||||
<button id="characterButton" class="ctrl-btn ctrl-btn--character" title="Create / customize your character">
|
||||
🎭 <span id="characterButtonLabel" class="lbl-full">CHARACTER</span><span class="lbl-short">CHAR</span>
|
||||
</button>
|
||||
<!-- Render mode + tile-pack picker (populated by spRenderPicker.js from entitlements). -->
|
||||
<div id="renderPicker" class="hidden"></div>
|
||||
<button id="shopButton" class="ctrl-btn ctrl-btn--shop" style="display: none;">
|
||||
🛒 SHOP
|
||||
</button>
|
||||
|
||||
+22
-6
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user