feat(render): re-enable render-kit SP dungeon with player-centered camera + goblin monster

Commit to the render kit as THE single-player renderer (legacy = hard fallback only). Fixes
the two things that broke it:
- CAMERA: each renderer now exposes focusPoint (the player's pixel pos); the SP bridge mounts
  a self-sizing 16:10 viewport host and CSS-transforms the canvas to centre the player at a
  readable zoom (wheel to zoom). Renderer-agnostic (tiled/iso/3d). No more whole-level-in-a-corner.
- MONSTER: the tiled renderer drew a hardcoded red dot; now it draws the Goblin character sprite
  (char-goblin, the same path the tavern draws characters), red orb only as last-resort fallback.
Re-enabled: _drawGameScreen prefers RK.SPGame; spRenderPicker ENABLED. Suite 62/435 green.

NEXT (still owed): make the splash + both waiting animations render through the kit too, so
they use the chosen pack/mode instead of the ROT text screens.
This commit is contained in:
jwinterm
2026-07-12 10:56:14 -04:00
parent 31b4c3ca5d
commit d925df1ae9
5 changed files with 74 additions and 18 deletions
+20 -10
View File
@@ -288,17 +288,27 @@ var Game = {
},
_drawGameScreen: function() {
// The render-kit dungeon path (RK.SPGame) is DISABLED until it has a player-centered camera
// + real monster/entity sprites — without those it rendered the whole level tiny in a corner
// with a red-dot monster. Until then the single-player game uses the proven legacy renderer.
if (window.RK && RK.SPGame && RK.SPGame.hide) RK.SPGame.hide();
if (!this._ensureDisplay()) return;
const gameState = GameState.getGameStateForRender();
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 */ }
// Render the dungeon through the render kit (Tiled / ASCII / Iso / 3D + unlocked packs) with
// a player-centered camera + goblin monster sprite. The legacy RenderEngine remains a HARD
// fallback (used 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
});
}
if (!rk) {
if (window.RK && RK.SPGame) RK.SPGame.hide();
if (!this._ensureDisplay()) return;
RenderEngine.drawGameScreen(gameState);
if (window.FX) {
try { this._fxSyncLighting(); } catch (e) { /* best-effort */ }
}
}
},
+2
View File
@@ -268,10 +268,12 @@
}
}
}
this.focusPoint = null;
for (var i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i];
p = this._project(e.x, e.y, originX, originY);
items.push({ type: 'entity', e: e, sx: p.x, sy: p.y, depth: e.x + e.y + 0.55 });
if (e.you || e.kind === 'player') this.focusPoint = { x: p.x, y: p.y + this.tileH }; // SP camera target
}
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
+37 -3
View File
@@ -16,7 +16,9 @@
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.
// A dedicated, SELF-SIZING child host so the RK canvas doesn't collide with the ROT canvas and
// doesn't depend on the ROT canvas for its dimensions (it's an aspect-ratio viewport the camera
// transform is clipped to).
function rkHost() {
var gd = gameDisplay();
if (!gd) return null;
@@ -24,7 +26,9 @@
if (!host) {
host = doc().createElement('div');
host.id = 'rk-game-host';
host.style.display = 'none';
host.style.cssText = 'display:none; position:relative; width:100%; max-width:900px;' +
' aspect-ratio:16/10; margin:0 auto; overflow:hidden; background:#0a0c0f;' +
' border:1px solid #2a313a; border-radius:6px; touch-action:none; cursor:grab;';
gd.appendChild(host);
}
return host;
@@ -67,10 +71,39 @@
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 (_) {} }
// The camera owns the canvas transform (centre on the player), so position it absolutely
// and let it overflow — do NOT use RK.attachZoom (it fights this transform).
if (SP._renderer && SP._renderer.canvas) {
var c = SP._renderer.canvas;
c.style.position = 'absolute'; c.style.top = '0'; c.style.left = '0';
c.style.maxWidth = 'none'; c.style.imageRendering = 'pixelated';
}
if (!host._rkZoomBound) {
host._rkZoomBound = true;
host.addEventListener('wheel', function (ev) {
ev.preventDefault();
SP._zoom = Math.max(0.6, Math.min(4, (SP._zoom || 1.7) * Math.exp(-ev.deltaY * 0.0015)));
SP._applyCamera();
}, { passive: false });
}
return SP._renderer;
};
// Centre the (whole-scene) canvas on the player via a CSS transform, clipped to the host —
// renderer-agnostic (works for tiled / iso / 3d, each of which reports its own focusPoint).
SP._applyCamera = function () {
var r = SP._renderer;
var host = doc() && doc().getElementById('rk-game-host');
if (!r || !r.canvas || !host) return;
var scale = SP._zoom || 1.7;
var fp = r.focusPoint;
r.canvas.style.transformOrigin = '0 0';
if (!fp) { r.canvas.style.transform = 'scale(' + scale + ')'; return; }
var w = host.clientWidth || 640, h = host.clientHeight || 400;
var tx = w / 2 - fp.x * scale, ty = h / 2 - fp.y * scale;
r.canvas.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
};
function toggleLegacy(hide) {
var gd = gameDisplay();
if (!gd) return;
@@ -117,6 +150,7 @@
isSpectating: opts.isSpectating
});
r.render(scene);
SP._applyCamera();
return true;
} catch (e) {
if (root.console) console.warn('SPGame render failed; falling back to legacy:', e && e.message);
+13 -2
View File
@@ -203,10 +203,17 @@
}
continue;
}
// Dungeon monster.
// Dungeon monster — draw the Goblin character sprite (same path the tavern draws
// characters through), falling back to a red orb only if the sprite is unavailable.
if (e.kind === 'monster') {
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.9, cell * 0.32, cell * 0.13);
var mAvatar = e.avatar || 'char-goblin';
if (window.RK && RK.avatarVisuals && RK.avatarVisuals.drawTopdownWorld) {
var mv = RK.avatarVisuals.resolve({ avatar: mAvatar }, { projection: 'topdown', context: 'dungeon', entity: e });
if (RK.avatarVisuals.drawTopdownWorld(ctx, mv, e, { screenX: e.x, screenY: e.y, cell: cell }, { now: now })) continue;
}
if (window.RK && RK.drawCharCanvas && RK.drawCharCanvas(ctx, { x: e.x, y: e.y, avatar: mAvatar }, cell, now)) continue;
var mx = e.x * cell + cell / 2, my = e.y * cell + cell / 2, mr = cell * 0.34;
drawEntityShadow(ctx, mx, e.y * cell + cell * 0.9, cell * 0.32, cell * 0.13);
ctx.beginPath();
ctx.arc(mx, my, mr, 0, Math.PI * 2);
ctx.fillStyle = e.color || '#f85149';
@@ -267,6 +274,10 @@
// Snapshot the base scene, then composite the torch light + vignette on top, and keep
// an animation loop running so the light flickers and embers drift between renders.
// Where the player is on this canvas — the SP camera centers the view on it.
var _fp = findPlayer(scene);
this.focusPoint = _fp ? { x: (_fp.x + 0.5) * cell, y: (_fp.y + 0.5) * cell } : null;
this._lastScene = scene;
this._collectEmitters(scene);
this._snapshot();
+2 -3
View File
@@ -30,9 +30,8 @@
doc().head.appendChild(s);
}
// Disabled until the single-player render-kit path (RK.SPGame) has a camera + entity sprites.
// Keeping the module wired so re-enabling is a one-line flip.
var ENABLED = false;
// Re-enabled now that the SP render kit has a player-centered camera + goblin sprite.
var ENABLED = true;
function build() {
var h = host();