diff --git a/html/js/render/fxLayer.js b/html/js/render/fxLayer.js new file mode 100644 index 0000000..b8cb2ac --- /dev/null +++ b/html/js/render/fxLayer.js @@ -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); diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js index 1e02438..392a752 100644 --- a/html/js/render/isoRenderer.js +++ b/html/js/render/isoRenderer.js @@ -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); diff --git a/html/js/render/sceneModel.js b/html/js/render/sceneModel.js index 56f814a..ec836af 100644 --- a/html/js/render/sceneModel.js +++ b/html/js/render/sceneModel.js @@ -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'; diff --git a/html/js/render/tileRenderer.js b/html/js/render/tileRenderer.js index a1398d9..436c25a 100644 --- a/html/js/render/tileRenderer.js +++ b/html/js/render/tileRenderer.js @@ -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; diff --git a/html/match.html b/html/match.html index 447ccf3..b379195 100644 --- a/html/match.html +++ b/html/match.html @@ -28,6 +28,7 @@ + diff --git a/html/tavern.html b/html/tavern.html index 6ae0589..287d71f 100644 --- a/html/tavern.html +++ b/html/tavern.html @@ -9,6 +9,7 @@ + diff --git a/src/multiplayer/tavernMap.js b/src/multiplayer/tavernMap.js index 7b1c5cc..50e43c6 100644 --- a/src/multiplayer/tavernMap.js +++ b/src/multiplayer/tavernMap.js @@ -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);