diff --git a/html/js/render/asciiRenderer.js b/html/js/render/asciiRenderer.js
index 62e107d..c08869d 100644
--- a/html/js/render/asciiRenderer.js
+++ b/html/js/render/asciiRenderer.js
@@ -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;
diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js
index a5a74c8..a0a5d05 100644
--- a/html/js/render/isoRenderer.js
+++ b/html/js/render/isoRenderer.js
@@ -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));
diff --git a/html/js/render/spGameRenderer.js b/html/js/render/spGameRenderer.js
index 03cbbcb..ba29703 100644
--- a/html/js/render/spGameRenderer.js
+++ b/html/js/render/spGameRenderer.js
@@ -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) {