feat(fx): RK.fx juice engine — animated torch/hearth flame + glow, hazard tiles

The shared "juice" layer, pure-canvas (CSP-safe, no WebGL/shaders), driven off a
clock so both renderers light a scene the same way:

- html/js/render/fxLayer.js (NEW): RK.fx — layered-sine flame flicker, additive
  warm glow, a dancing two-layer canvas flame, and pulsing hazard overlays
  (lava/poison/spikes) with a footprint-clip callback so it fits square cells
  and iso diamonds alike.
- Tavern now has FIRE: 4 corner braziers + a hearth on the left wall throw real
  flickering light. Placed along walls, clear of spawn/aisles/tables.
- Both renderers: torch/hearth/hazard tiles draw only their FLOOR base in the
  static pass (no more solid amber block), then RK.fx paints the animated flame
  / hazard pulse in the live loop. Tiled caches emitter positions and draws them
  over the vignette so light punches through the dark edges; iso adds them as
  depth-sorted fx items and keeps its RAF alive while any fire/hazard is onscreen.
- Dungeon hazard zones wired end-to-end in the scene model (lava L / poison P /
  spikes ^ chars -> pulsing tiles) — they render the moment the generator emits
  them; no client-authoritative fakes.
- fxLayer.js included in tavern.html + match.html.

Suite 58/419 green; render files node --check clean.
This commit is contained in:
jwinterm
2026-07-11 17:00:43 -04:00
parent 4d673748df
commit 11de46a7e4
7 changed files with 224 additions and 10 deletions
+118
View File
@@ -0,0 +1,118 @@
// fxLayer.js — the shared "juice" engine (RK.fx). Pure-canvas animated FX (no WebGL, no shaders,
// CSP-safe) used by BOTH the top-down tiled renderer and the iso renderer so a scene lights up the
// same way in either projection: dancing torch/hearth flame + warm glow, and pulsing hazard tiles
// (lava / poison / spikes) for the dungeon. Everything is a function of `now` (ms) so callers just
// keep a RAF loop alive and pass the clock — no per-particle state to thread through.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
var fx = RK.fx = RK.fx || {};
// Fire flicker → 0..1. Layered sines at incommensurate rates so it reads as a living flame
// rather than an obvious pulse. `seed` decorrelates neighbouring torches.
fx.flicker = function (now, seed) {
seed = seed || 0;
var t = now / 1000;
var v = 0.62
+ 0.20 * Math.sin(t * 11.3 + seed * 1.7)
+ 0.11 * Math.sin(t * 24.1 + seed * 4.3)
+ 0.07 * Math.sin(t * 3.7 + seed * 0.9);
return v < 0 ? 0 : (v > 1 ? 1 : v);
};
// Smooth 0..1 pulse for hazards (slower, sinusoidal breathing).
fx.pulse = function (now, period, seed) {
period = period || 1400;
return 0.5 + 0.5 * Math.sin((now / period) * Math.PI * 2 + (seed || 0));
};
// Additive warm/tinted glow disc centred on (cx, cy).
fx.glow = function (ctx, cx, cy, radius, opts) {
opts = opts || {};
if (radius <= 0) return;
var rgb = opts.rgb || '255,180,90';
var a = opts.intensity == null ? 0.5 : opts.intensity;
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
g.addColorStop(0, 'rgba(' + rgb + ',' + a + ')');
g.addColorStop(0.45, 'rgba(' + rgb + ',' + (a * 0.35) + ')');
g.addColorStop(1, 'rgba(' + rgb + ',0)');
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
};
function flamePath(ctx, cx, cy, w, h, sway) {
ctx.beginPath();
ctx.moveTo(cx - w / 2, cy);
ctx.quadraticCurveTo(cx - w * 0.5 + sway, cy - h * 0.55, cx + sway, cy - h);
ctx.quadraticCurveTo(cx + w * 0.5 + sway, cy - h * 0.55, cx + w / 2, cy);
ctx.quadraticCurveTo(cx, cy + h * 0.14, cx - w / 2, cy);
ctx.closePath();
}
// A dancing flame whose BASE sits at (cx, cy) and licks upward. `scale` ≈ flame width.
fx.flame = function (ctx, cx, cy, scale, now, seed) {
seed = seed || 0;
var f = fx.flicker(now, seed);
var sway = Math.sin(now / 150 + seed) * scale * 0.16;
var h = scale * (1.5 + f * 0.7);
var w = scale * 0.95;
ctx.save();
ctx.globalCompositeOperation = 'lighter';
// Outer flame (orange envelope).
var og = ctx.createRadialGradient(cx, cy - h * 0.45, 0, cx, cy - h * 0.4, h);
og.addColorStop(0, 'rgba(255,225,150,0.95)');
og.addColorStop(0.4, 'rgba(255,140,40,0.72)');
og.addColorStop(1, 'rgba(190,40,10,0)');
ctx.fillStyle = og;
flamePath(ctx, cx, cy, w, h, sway);
ctx.fill();
// Inner core (white-hot).
var ig = ctx.createRadialGradient(cx + sway * 0.5, cy - h * 0.32, 0, cx + sway * 0.5, cy - h * 0.3, h * 0.55);
ig.addColorStop(0, 'rgba(255,255,238,0.95)');
ig.addColorStop(1, 'rgba(255,205,95,0)');
ctx.fillStyle = ig;
flamePath(ctx, cx, cy, w * 0.52, h * 0.62, sway * 0.6);
ctx.fill();
ctx.restore();
};
// A fire emitter = flame + a soft warm ground glow. Used for torches (small) and hearths
// (large) alike; `scale` drives both.
fx.fire = function (ctx, cx, cy, scale, now, seed) {
var f = fx.flicker(now, seed || 0);
fx.glow(ctx, cx, cy - scale * 0.2, scale * (3.4 + f * 0.6), { rgb: '255,170,80', intensity: 0.16 + f * 0.12 });
fx.flame(ctx, cx, cy, scale, now, seed || 0);
};
// Hazard palettes. `rgb` = the emissive heat colour; `base` = the flat tint painted into the
// tile footprint; `period` = pulse speed (ms).
var HAZARD = {
lava: { rgb: '255,110,30', base: 'rgba(70,15,5,0.55)', period: 1500 },
poison: { rgb: '90,230,90', base: 'rgba(15,50,20,0.50)', period: 2200 },
spikes: { rgb: '170,190,215', base: 'rgba(20,24,30,0.35)', period: 1000 }
};
fx.isHazard = function (kind) { return !!HAZARD[kind]; };
fx.hazardKinds = function () { return Object.keys(HAZARD); };
// Pulsing hazard overlay. The renderer passes a `drawFootprint(ctx)` callback that traces the
// tile shape (a square cell top-down, a diamond in iso) so the flat tint clips correctly; the
// heat glow rides on top additively.
fx.hazard = function (ctx, kind, cx, cy, radius, now, drawFootprint) {
var s = HAZARD[kind];
if (!s) return;
var p = fx.pulse(now, s.period, cx * 0.13 + cy * 0.07);
if (drawFootprint) {
ctx.save();
drawFootprint(ctx);
ctx.fillStyle = s.base;
ctx.fill();
ctx.restore();
}
fx.glow(ctx, cx, cy, radius * (1.0 + p * 0.25), { rgb: s.rgb, intensity: 0.16 + p * 0.4 });
};
})(typeof window !== 'undefined' ? window : this);
+22 -1
View File
@@ -250,15 +250,22 @@
// Furniture kinds draw a floor tile as the base + the prop sprite on top (same idea as the
// tiled renderer's `over` compositing). Everything else is a plain ground/wall tile.
var PROP = { bar: 1, table: 1, chair: 1, keg: 1, shelf: 1, barrel: 1, crate: 1, chest: 1 };
var items = [], x, y, kind, p;
var legend = scene.legend || {};
var items = [], x, y, kind, p, def;
for (y = 0; y < scene.rows; y++) {
for (x = 0; x < scene.cols; x++) {
kind = scene.grid[y][x];
def = legend[kind];
p = this._project(x, y, originX, originY);
items.push({ type: 'tile', kind: kind, x: x, y: y, sx: p.x, sy: p.y, depth: x + y });
if (PROP[kind]) {
items.push({ type: 'prop', kind: kind, x: x, y: y, sx: p.x, sy: p.y, depth: x + y + 0.25 });
}
// Fire fixtures (torch/hearth) & hazard tiles (lava/poison/spikes): a floor tile is
// the base (drawn above via fallback); RK.fx paints the animated flame / pulse.
if (def && (def.fx === 'fire' || def.hazard)) {
items.push({ type: 'fx', def: def, x: x, y: y, sx: p.x, sy: p.y, depth: x + y + 0.3 });
}
}
}
for (var i = 0; i < scene.entities.length; i++) {
@@ -281,6 +288,20 @@
this._contactShadow(it.sx, it.sy + this.tileH * 1.4, 22, 9);
rec = this._load(this._tileUrl(it.kind));
if (rec && rec.ready) this._drawImage(rec.img, it.sx, it.sy + this.tileH, this.imageW, this.imageH);
} else if (it.type === 'fx' && root.RK && RK.fx) {
this._animating = true; // keep the RAF alive so the flame/pulse animates
var fcx = it.sx, fgy = it.sy + this.tileH * 1.15;
if (it.def.hazard) {
var hw = this.tileW / 2, hh = this.tileH / 2, hy = it.sy + this.tileH;
RK.fx.hazard(ctx, it.def.hazard, fcx, hy, this.tileW * 0.5, now, function (c) {
c.beginPath();
c.moveTo(fcx, hy - hh); c.lineTo(fcx + hw, hy);
c.lineTo(fcx, hy + hh); c.lineTo(fcx - hw, hy); c.closePath();
});
} else {
var fscale = this.tileW * (it.def.fxScale || 0.34) * 0.7;
RK.fx.fire(ctx, fcx, fgy, fscale, now, (it.x * 7 + it.y * 13) % 97);
}
} else if (it.type === 'entity') {
this._contactShadow(it.sx, it.sy + this.tileH * 1.15, 18, 7);
var frame = this._charFrame(it.e, now);
+15 -4
View File
@@ -22,14 +22,17 @@
chair: { char: 'c', color: '#5a4028', solid: false, over: 'floor' }, // walkable — stand on it to "sit"
barrel: { char: 'B', color: '#5a4028', solid: true, over: 'floor' },
crate: { char: 'C', color: '#7a5a38', solid: true, over: 'floor' },
door: { char: 'D', color: '#3fb950', solid: false, over: 'floor' }
door: { char: 'D', color: '#3fb950', solid: false, over: 'floor' },
// Fire fixtures — the base tile is floor; RK.fx paints the animated flame + glow on top.
torch: { char: 'i', color: '#d29922', solid: true, over: 'floor', fx: 'fire', fxScale: 0.34 },
hearth: { char: 'F', color: '#e0742a', solid: true, over: 'floor', fx: 'fire', fxScale: 0.7 }
};
// Layout character -> tile kind.
var TAVERN_TILE_OF = {
'#': 'wall', 'W': 'window', '.': 'floor', '@': 'floor', 'r': 'rug',
'=': 'bar', 'k': 'keg', 'h': 'shelf', 'T': 'table', 'c': 'chair',
'B': 'barrel', 'C': 'crate', 'D': 'door'
'B': 'barrel', 'C': 'crate', 'D': 'door', 'i': 'torch', 'F': 'hearth'
};
var AVATAR_COLORS = { 'default': '#9aa4b2', green: '#3fb950', amber: '#d29922', red: '#f85149' };
@@ -101,14 +104,22 @@
entrance: { char: '<', color: '#3fb950', solid: false },
exit: { char: '>', color: '#d29922', solid: false },
treasure: { char: '$', color: '#fbbf24', solid: false },
torch: { char: '#', color: '#3a3028', solid: true },
torch: { char: 'i', color: '#d29922', solid: false, fx: 'fire', fxScale: 0.34 },
// Hazard zones. The base tile stays walkable/ground; RK.fx paints the pulsing overlay. These
// render as soon as the dungeon generator emits their chars (L/P/^) in the tile stream.
lava: { char: '≈', color: '#7a2a0e', solid: false, hazard: 'lava' },
poison: { char: '≈', color: '#1c4a24', solid: false, hazard: 'poison' },
spikes: { char: '^', color: '#3a3f48', solid: false, hazard: 'spikes' },
dark: { char: ' ', color: '#0a0c0f', solid: true }
};
// Map dungeon tile characters to scene tile kinds.
function dungeonTileKind(ch) {
if (ch === '#') return 'wall';
if (ch === 'torch') return 'torch';
if (ch === 'torch' || ch === 'i') return 'torch';
if (ch === 'L') return 'lava';
if (ch === 'P') return 'poison';
if (ch === '^') return 'spikes';
if (ch === "'1" || ch === 0) return 'floor';
if (ch === "'2") return 'floor2';
if (ch === '<') return 'entrance';
+57
View File
@@ -105,6 +105,21 @@
var kind = scene.grid[y][x];
var px = x * cell, py = y * cell;
var def = scene.legend[kind] || { color: '#333' };
// Fire fixtures (torch/hearth) & hazard tiles (lava/poison/spikes) draw only
// their ground base here; RK.fx paints the animated flame / pulse over the
// snapshot in the flicker loop so they actually move. Skip the solid colour block.
if (def.fx === 'fire' || def.hazard) {
var baseKind = def.over || 'floor';
var bco = useAtlas ? pickVariant(tmap[baseKind], x, y) : null;
if (bco) atlas.draw(ctx, bco[0], bco[1], px, py, cell);
else {
var bdef = scene.legend[baseKind] || def;
ctx.fillStyle = bdef.color || def.color;
ctx.fillRect(px, py, cell, cell);
}
applyCellLight(ctx, scene, x, y, px, py, cell);
continue;
}
var coord = useAtlas ? pickVariant(tmap[kind], x, y) : null;
if (coord) {
// Object tiles (furniture, windows, torches, treasure…) are transparent
@@ -253,12 +268,34 @@
// 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.
this._lastScene = scene;
this._collectEmitters(scene);
this._snapshot();
this._ensureEmbers(scene);
this._composite(scene);
this._startLoop();
};
// Cache the fire/hazard tile positions once per scene so the flicker loop can animate them
// without rescanning the grid every frame.
TileRenderer.prototype._collectEmitters = function (scene) {
var em = [], cell = this.cell, legend = scene.legend || {};
for (var y = 0; y < scene.rows; y++) {
for (var x = 0; x < scene.cols; x++) {
var def = legend[scene.grid[y][x]];
if (!def) continue;
if (def.fx === 'fire') {
em.push({ fire: true, px: x * cell, py: y * cell, cx: x * cell + cell / 2,
fy: y * cell + cell * 0.82, scale: cell * (def.fxScale || 0.34),
seed: (x * 7 + y * 13) % 97 });
} else if (def.hazard) {
em.push({ hazard: def.hazard, px: x * cell, py: y * cell,
cx: x * cell + cell / 2, cy: y * cell + cell / 2 });
}
}
}
this._emitters = em;
};
// Soft drop shadow (an ellipse) under an entity's feet.
function drawEntityShadow(ctx, cx, cy, rx, ry) {
ctx.save();
@@ -393,8 +430,28 @@
ctx.fillStyle = vig;
ctx.fillRect(0, 0, w, h);
ctx.restore();
// Animated fire fixtures + hazard tiles — drawn over the vignette so their light punches
// through the dark edges rather than being dimmed by it.
var em = this._emitters;
if (em && em.length && root.RK && RK.fx) {
var nowMs = Date.now();
for (var k = 0; k < em.length; k++) {
var it = em[k];
if (it.hazard) {
RK.fx.hazard(ctx, it.hazard, it.cx, it.cy, cell * 0.9, nowMs, makeRect(it.px, it.py, cell));
} else {
RK.fx.fire(ctx, it.cx, it.fy, it.scale, nowMs, it.seed);
}
}
}
};
// Footprint tracer for a square cell (RK.fx.hazard clips its base tint to this).
function makeRect(px, py, cell) {
return function (c) { c.beginPath(); c.rect(px, py, cell, cell); };
}
TileRenderer.prototype._startLoop = function () {
if (this._raf != null) return;
var self = this;
+1
View File
@@ -28,6 +28,7 @@
<!-- Render kit -->
<script src="js/render/sceneModel.js"></script>
<script src="js/render/fxLayer.js"></script>
<script src="js/render/skins.js"></script>
<script src="js/render/atlas.js"></script>
<script src="js/render/charSprites.js"></script>
+1
View File
@@ -9,6 +9,7 @@
<!-- PixiJS (+ @pixi/unsafe-eval for the CSP) is lazy-loaded by renderModes.js only when the
Fancy mode is first selected, so ASCII/Tiled users never download the WebGL library. -->
<script src="js/render/sceneModel.js"></script>
<script src="js/render/fxLayer.js"></script>
<script src="js/render/skins.js"></script>
<script src="js/render/charCustomize.js"></script>
<script src="js/render/atlas.js"></script>
+10 -5
View File
@@ -51,13 +51,18 @@ function buildDefaultTavern(cols = 28, rows = 15) {
// Corner decor — barrels and crates.
put(1, 1, 'B'); put(cols - 2, 1, 'C'); put(1, rows - 2, 'C'); put(cols - 2, rows - 2, 'B');
// Barrels & crates lining the side walls (one in from the wall, clear of the windows/spawn) so
// the room reads lived-in instead of a big empty floor. Alternating B/C for material variety.
// Fixtures lining the room so it reads lived-in instead of a big empty floor: barrels/crates
// (material variety) plus fire fixtures — braziers ('i') throwing flickering light in the
// corners and a hearth ('F') anchoring the left wall. All solid, all clear of windows/spawn/
// tables/aisles. The renderers paint the animated flame + glow on the fire tiles.
const wallDecor = [
[1, 5, 'B'], [1, 9, 'C'], [1, 12, 'B'],
[cols - 2, 5, 'C'], [cols - 2, 9, 'B'], [cols - 2, 12, 'C'],
[1, 6, 'B'], [1, 10, 'C'],
[cols - 2, 6, 'C'], [cols - 2, 10, 'B'],
[2, rows - 2, 'B'], [cols - 3, rows - 2, 'C'],
[barX0 - 1, barY + 1, 'B'], [barX1 + 1, barY + 1, 'C'] // a barrel/crate tucked beside each keg
[barX0 - 1, barY + 1, 'B'], [barX1 + 1, barY + 1, 'C'], // a barrel/crate tucked beside each keg
[1, 4, 'i'], [cols - 2, 4, 'i'], // braziers flanking the bar
[1, rows - 3, 'i'], [cols - 2, rows - 3, 'i'], // braziers in the lower corners
[1, 8, 'F'] // hearth on the left wall
];
for (const [dx, dy, dch] of wallDecor) put(dx, dy, dch);