From d925df1ae9b3b7d50c05b4a43de3c8341758e3c3 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sun, 12 Jul 2026 10:56:14 -0400 Subject: [PATCH] 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. --- html/js/core/game.js | 30 ++++++++++++++++-------- html/js/render/isoRenderer.js | 2 ++ html/js/render/spGameRenderer.js | 40 +++++++++++++++++++++++++++++--- html/js/render/tileRenderer.js | 15 ++++++++++-- html/js/ui/spRenderPicker.js | 5 ++-- 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/html/js/core/game.js b/html/js/core/game.js index 84fb646..07d6e7d 100644 --- a/html/js/core/game.js +++ b/html/js/core/game.js @@ -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 */ } + } } }, diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js index 392a752..2392be0 100644 --- a/html/js/render/isoRenderer.js +++ b/html/js/render/isoRenderer.js @@ -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; }); diff --git a/html/js/render/spGameRenderer.js b/html/js/render/spGameRenderer.js index e255736..7f47456 100644 --- a/html/js/render/spGameRenderer.js +++ b/html/js/render/spGameRenderer.js @@ -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); diff --git a/html/js/render/tileRenderer.js b/html/js/render/tileRenderer.js index 436c25a..ccf9574 100644 --- a/html/js/render/tileRenderer.js +++ b/html/js/render/tileRenderer.js @@ -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(); diff --git a/html/js/ui/spRenderPicker.js b/html/js/ui/spRenderPicker.js index e1b063a..02205e9 100644 --- a/html/js/ui/spRenderPicker.js +++ b/html/js/ui/spRenderPicker.js @@ -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();