fix(render): 3D lazy-loads THREE; ASCII torch lights; iso never-black placeholder

Three mode-switch issues:
- Picking 3D gave "original": SP._ensureRenderer used the sync createRenderer, which falls back to
  Tiled when THREE (lazy CDN load) is not ready. Now it kicks off RK.ensureThree and re-mounts as
  real 3D once loaded (renders Tiled meanwhile so it is not blank).
- ASCII torches were inert glyphs: they now draw a warm flickering radial glow (actual light source)
  with the glyph at full brightness, driven by a throttled ~22fps RAF (cancelled on destroy).
- Switching to iso could show black while the pack images load: tiles now draw a legend-coloured iso
  diamond placeholder until the real image is ready, so the dungeon shape + camera always read.
This commit is contained in:
jwinterm
2026-07-13 14:55:26 -04:00
parent aabf20405f
commit b45c0851c5
3 changed files with 77 additions and 9 deletions
+48 -8
View File
@@ -16,7 +16,8 @@
AsciiRenderer.prototype.render = function (scene) {
if (!scene) return;
var cell = this.cell, ctx = this.ctx;
this.lastScene = scene;
var cell = this.cell, ctx = this.ctx, now = Date.now(), hasTorch = false;
this.canvas.width = scene.cols * cell;
this.canvas.height = scene.rows * cell;
@@ -38,14 +39,33 @@
ctx.fillText(walk ? '·' : '#', x * cell + cell / 2, y * cell + cell / 2);
} else {
var def = scene.legend[scene.grid[y][x]] || { char: '?', color: '#555' };
ctx.fillStyle = def.color;
// Dungeon lighting: dim tiles in shadow.
if (scene.lightGrid && scene.lightGrid[y] && scene.lightGrid[y][x] != null) {
var b = scene.lightGrid[y][x];
if (b < 1) ctx.globalAlpha = b;
var gx = x * cell + cell / 2, gy = y * cell + cell / 2;
if (def.fx === 'fire') {
// Torches are ACTUAL light sources: a warm radial glow behind the glyph that
// gently flickers; the glyph itself stays at full brightness (never dimmed).
hasTorch = true;
var seed = x * 7 + y * 13;
var fl = 0.7 + Math.sin(now / 170 + seed) * 0.3;
var rad = cell * (1.9 + Math.sin(now / 240 + seed) * 0.18);
var g = ctx.createRadialGradient(gx, gy, 0, gx, gy, rad);
g.addColorStop(0, 'rgba(255,196,96,' + (0.6 * fl) + ')');
g.addColorStop(0.45, 'rgba(255,140,44,' + (0.24 * fl) + ')');
g.addColorStop(1, 'rgba(255,120,30,0)');
ctx.globalAlpha = 1;
ctx.fillStyle = g;
ctx.fillRect(gx - rad, gy - rad, rad * 2, rad * 2);
ctx.fillStyle = '#ffd9a0';
ctx.fillText(def.char, gx, gy);
} else {
ctx.fillStyle = def.color;
// Dungeon lighting: dim tiles in shadow.
if (scene.lightGrid && scene.lightGrid[y] && scene.lightGrid[y][x] != null) {
var b = scene.lightGrid[y][x];
if (b < 1) ctx.globalAlpha = b;
}
ctx.fillText(def.char, gx, gy);
ctx.globalAlpha = 1;
}
ctx.fillText(def.char, x * cell + cell / 2, y * cell + cell / 2);
ctx.globalAlpha = 1;
}
}
}
@@ -95,9 +115,29 @@
break;
}
}
// Keep a light RAF alive so the torch glows flicker between game updates (~22fps).
this._hasTorches = hasTorch;
if (hasTorch) this._scheduleFlicker();
};
// Re-render on a throttled RAF so torch glows breathe even when the game isn't updating.
AsciiRenderer.prototype._scheduleFlicker = function () {
if (this._flickerRaf || !this._hasTorches || !this.ctx) return;
var self = this;
this._flickerRaf = requestAnimationFrame(function () {
self._flickerRaf = null;
if (!self.ctx || !self._hasTorches || !self.lastScene) return;
var t = Date.now();
if (self._lastFlick && t - self._lastFlick < 45) { self._scheduleFlicker(); return; }
self._lastFlick = t;
self.render(self.lastScene);
});
};
AsciiRenderer.prototype.destroy = function () {
this._hasTorches = false;
if (this._flickerRaf) { cancelAnimationFrame(this._flickerRaf); this._flickerRaf = null; }
if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
this.canvas = null;
this.ctx = null;
+12
View File
@@ -73,6 +73,17 @@
this.ctx.drawImage(img, cx - w / 2, baseY - h, w, h);
};
// A flat iso diamond in the legend colour — the placeholder a tile draws while its pack image
// loads (or if an image is missing), so the view shows the dungeon shape instead of going black.
IsoRenderer.prototype._diamond = function (cx, cy, color) {
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
ctx.beginPath();
ctx.moveTo(cx, cy - hh); ctx.lineTo(cx + hw, cy); ctx.lineTo(cx, cy + hh); ctx.lineTo(cx - hw, cy);
ctx.closePath();
ctx.fillStyle = color; ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.28)'; ctx.lineWidth = 1; ctx.stroke();
};
// Soft contact shadow grounds props/characters so they don't float on the floor.
IsoRenderer.prototype._contactShadow = function (cx, cy, rx, ry) {
var ctx = this.ctx;
@@ -286,6 +297,7 @@
else tileKind = this._floorVariant(tileKind, it.x, it.y);
var rec = this._load(this._tileUrl(tileKind));
if (rec && rec.ready) this._drawImage(rec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
else if (it.kind !== 'dark') this._diamond(it.sx, it.sy + this.tileH * 0.5, (legend[it.kind] && legend[it.kind].color) || '#3a4048');
} else if (it.type === 'prop') {
this._contactShadow(it.sx, it.sy + this.tileH * 1.4, 22, 9);
rec = this._load(this._tileUrl(it.kind));
+17 -1
View File
@@ -75,7 +75,23 @@
if (SP._renderer) return SP._renderer;
var host = rkHost();
if (!host || !RK.createRenderer) return null;
SP._renderer = RK.createRenderer(SP.mode(), host, { cell: 24 });
var mode = SP.mode();
// 3D needs THREE, lazy-loaded from a CDN. The sync createRenderer() silently returns a Tiled
// renderer when THREE isn't loaded yet ("picked 3D, got original"). Kick off the load and
// re-mount as real 3D once it's ready; meanwhile we render Tiled so the screen isn't blank.
if (mode === '3d' && RK.ensureThree && RK.threeReady && !RK.threeReady()) {
if (!SP._threePending) {
SP._threePending = true;
RK.ensureThree(function (ok) {
SP._threePending = false;
if (ok && SP._live && SP.mode() === '3d') {
SP._destroyRenderer();
if (SP._lastState) SP.render(SP._lastState, SP._lastOpts);
}
});
}
}
SP._renderer = RK.createRenderer(mode, host, { cell: 24 });
// 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) {