mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
532 lines
21 KiB
JavaScript
532 lines
21 KiB
JavaScript
// FXOverlay: dependency-free 2D-canvas FX layer for the FREE ROT.js dungeon (Pipeline A).
|
|
// Ports the premium FancyRenderer look (warm flickering torch light, screen-edge vignette,
|
|
// drifting-ember particle field, transient sparkles/bursts, full-screen color flash and a
|
|
// decaying screen shake) using plain <canvas> radial gradients instead of Pixi.
|
|
//
|
|
// Exposes a single global `window.FX`. EVERY method is a no-op when there is no
|
|
// #game-display / ROT base canvas present (so match.html & tavern.html, which never
|
|
// include this file, and any early-boot call are safe). It draws onto its OWN
|
|
// absolutely-positioned canvas layered just above the avatar overlay (z-index 7,
|
|
// pointer-events:none) and reuses the same overlay-sync math as
|
|
// SinglePlayerAvatar._syncOverlay. It runs its own requestAnimationFrame loop for
|
|
// flicker/embers/particles, and self-suspends (cancelAnimationFrame: no leaks) once the
|
|
// scene is idle; stop() hard-cancels it. Coordinates are in base-canvas pixel space.
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
var doc = root.document;
|
|
|
|
function now() {
|
|
return (root.performance && root.performance.now) ? root.performance.now() : Date.now();
|
|
}
|
|
|
|
// A soft radial-gradient sprite baked onto an offscreen canvas (mirrors
|
|
// FancyRenderer.radialTexture, but returns a <canvas> we blit with drawImage).
|
|
function radialSprite(size, stops) {
|
|
if (!doc) return null;
|
|
var c = doc.createElement('canvas');
|
|
c.width = c.height = size;
|
|
var ctx = c.getContext('2d');
|
|
if (!ctx) return null;
|
|
var g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
for (var i = 0; i < stops.length; i++) g.addColorStop(stops[i][0], stops[i][1]);
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, size, size);
|
|
return c;
|
|
}
|
|
|
|
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
|
|
|
|
var FX = {
|
|
_base: null, // ROT base canvas (fallback if DisplayManager is unavailable)
|
|
_host: null, // #game-display container
|
|
_canvas: null, // our overlay canvas
|
|
_ctx: null,
|
|
_hostOrig: null, // saved inline transform of the host (for shake restore)
|
|
|
|
_running: false,
|
|
_raf: 0,
|
|
_lastT: 0,
|
|
_t: 0, // seconds accumulator for flicker/drift
|
|
|
|
_active: false, // a dungeon torch is being driven (renderLighting called)
|
|
_forceAmbient: false, // screens can force the ambient scene without a player torch
|
|
_light: { x: 0, y: 0, cell: 32, radius: 6.5, color: null, intensity: 1 },
|
|
|
|
_embers: null, // ambient drifting-ember field
|
|
_particles: [], // transient sparkles / bursts
|
|
_flash: null, // { color, alpha, t, dur }
|
|
_shake: null, // { mag, t, dur }
|
|
|
|
_texLight: null,
|
|
_texEmber: null,
|
|
_texVig: null,
|
|
_vigKey: '',
|
|
_sizeKey: '',
|
|
_tint: {}, // css-color -> tinted mote canvas (cache)
|
|
_frameBound: null,
|
|
|
|
// ---- texture bootstrap -------------------------------------------------
|
|
_initTex: function () {
|
|
if (this._texLight) return;
|
|
this._texLight = radialSprite(160, [
|
|
[0, 'rgba(255,205,130,0.95)'],
|
|
[0.45, 'rgba(255,160,80,0.40)'],
|
|
[1, 'rgba(255,150,70,0)']
|
|
]);
|
|
this._texEmber = radialSprite(48, [
|
|
[0, 'rgba(255,228,182,0.95)'],
|
|
[1, 'rgba(255,228,182,0)']
|
|
]);
|
|
},
|
|
|
|
_tintedMote: function (color) {
|
|
color = color || 'rgba(255,228,182,1)';
|
|
if (this._tint[color]) return this._tint[color];
|
|
if (!doc) return null;
|
|
var size = 64;
|
|
var c = doc.createElement('canvas');
|
|
c.width = c.height = size;
|
|
var ctx = c.getContext('2d');
|
|
if (!ctx) return null;
|
|
var g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
try {
|
|
g.addColorStop(0, color);
|
|
g.addColorStop(0.4, color);
|
|
g.addColorStop(1, 'rgba(0,0,0,0)');
|
|
ctx.fillStyle = g;
|
|
} catch (_) {
|
|
ctx.fillStyle = 'rgba(255,228,182,0.9)';
|
|
}
|
|
ctx.fillRect(0, 0, size, size);
|
|
this._tint[color] = c;
|
|
return c;
|
|
},
|
|
|
|
// ---- overlay sync (mirrors SinglePlayerAvatar._syncOverlay) -------------
|
|
_resolveBase: function () {
|
|
if (root.DisplayManager && root.DisplayManager.getDisplay) {
|
|
var d = root.DisplayManager.getDisplay();
|
|
if (d && d.getContainer) {
|
|
var c = d.getContainer();
|
|
if (c) return c;
|
|
}
|
|
}
|
|
return this._base || null;
|
|
},
|
|
|
|
// Returns the 2D context of a correctly sized/positioned overlay, or null
|
|
// (which makes every public method a no-op: keeps other pages unaffected).
|
|
_ensure: function () {
|
|
if (!doc) return null;
|
|
var base = this._resolveBase();
|
|
var host = doc.getElementById('game-display');
|
|
if (!base || !host) return null;
|
|
this._host = host;
|
|
|
|
if (!this._canvas) {
|
|
var cv = doc.createElement('canvas');
|
|
cv.className = 'fx-overlay';
|
|
cv.style.position = 'absolute';
|
|
cv.style.pointerEvents = 'none';
|
|
cv.style.zIndex = '7'; // just above the avatar overlay (z-index 6)
|
|
cv.style.left = '0px';
|
|
cv.style.top = '0px';
|
|
// Override the retro `.rotdis canvas { image-rendering: pixelated }` rule so
|
|
// the soft light/vignette gradients scale smoothly rather than blocky.
|
|
cv.style.imageRendering = 'auto';
|
|
this._canvas = cv;
|
|
this._ctx = cv.getContext('2d');
|
|
}
|
|
// If a fresh ROT display replaced the host contents, re-attach.
|
|
if (this._canvas.parentNode !== host) host.appendChild(this._canvas);
|
|
if (!this._ctx) this._ctx = this._canvas.getContext('2d');
|
|
if (!this._ctx) return null;
|
|
|
|
this._initTex();
|
|
|
|
var sizeKey = base.width + 'x' + base.height;
|
|
if (sizeKey !== this._sizeKey) {
|
|
if (base.width) this._canvas.width = base.width;
|
|
if (base.height) this._canvas.height = base.height;
|
|
this._sizeKey = sizeKey;
|
|
this._buildVignette();
|
|
this._seedEmbers();
|
|
}
|
|
this._canvas.style.left = base.offsetLeft + 'px';
|
|
this._canvas.style.top = base.offsetTop + 'px';
|
|
this._canvas.style.width = base.offsetWidth + 'px';
|
|
this._canvas.style.height = base.offsetHeight + 'px';
|
|
this._canvas.style.display = 'block';
|
|
return this._ctx;
|
|
},
|
|
|
|
_buildVignette: function () {
|
|
if (!doc || !this._canvas) return;
|
|
var w = this._canvas.width, h = this._canvas.height;
|
|
if (!w || !h) { this._texVig = null; return; }
|
|
var c = doc.createElement('canvas');
|
|
c.width = w; c.height = h;
|
|
var ctx = c.getContext('2d');
|
|
if (!ctx) { this._texVig = null; return; }
|
|
var r = Math.max(w, h) * 0.72;
|
|
var g = ctx.createRadialGradient(w / 2, h / 2, r * 0.35, w / 2, h / 2, r);
|
|
g.addColorStop(0, 'rgba(0,0,0,0)');
|
|
g.addColorStop(0.62, 'rgba(0,0,0,0)');
|
|
g.addColorStop(1, 'rgba(0,0,0,0.62)');
|
|
ctx.fillStyle = g;
|
|
ctx.fillRect(0, 0, w, h);
|
|
this._texVig = c;
|
|
},
|
|
|
|
_seedEmbers: function () {
|
|
if (!this._canvas) return;
|
|
var w = this._canvas.width, h = this._canvas.height;
|
|
if (!w || !h) { this._embers = []; return; }
|
|
var n = Math.round(clamp((w * h) / 16000, 10, 46));
|
|
var list = [];
|
|
for (var i = 0; i < n; i++) {
|
|
list.push({
|
|
x: Math.random() * w,
|
|
y: Math.random() * h,
|
|
vx: (Math.random() - 0.5) * 8,
|
|
vy: -(6 + Math.random() * 14),
|
|
size: 3 + Math.random() * 6,
|
|
ph: Math.random() * 6.28,
|
|
baseA: 0.10 + Math.random() * 0.16
|
|
});
|
|
}
|
|
this._embers = list;
|
|
},
|
|
|
|
// ---- lifecycle ---------------------------------------------------------
|
|
_gameActive: function () {
|
|
var g = root.Game;
|
|
if (!g) return true; // no host game object -> assume caller knows best
|
|
try { return !!g._gameActive; } catch (_) { return true; }
|
|
},
|
|
|
|
_sceneOn: function () {
|
|
return (this._active && this._gameActive()) || this._forceAmbient;
|
|
},
|
|
|
|
_busy: function () {
|
|
return this._sceneOn() || this._particles.length > 0 || !!this._flash || !!this._shake;
|
|
},
|
|
|
|
start: function () {
|
|
if (!this._ensure()) return this;
|
|
if (this._running) return this;
|
|
if (!root.requestAnimationFrame) return this;
|
|
this._running = true;
|
|
this._lastT = 0;
|
|
this._raf = root.requestAnimationFrame(this._frameBound);
|
|
return this;
|
|
},
|
|
|
|
stop: function () {
|
|
this._running = false;
|
|
if (this._raf && root.cancelAnimationFrame) root.cancelAnimationFrame(this._raf);
|
|
this._raf = 0;
|
|
this._active = false;
|
|
this._particles = [];
|
|
this._flash = null;
|
|
this._shake = null;
|
|
this._resetHostTransform();
|
|
if (this._ctx && this._canvas) {
|
|
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
|
}
|
|
return this;
|
|
},
|
|
|
|
clear: function () {
|
|
this._particles = [];
|
|
this._flash = null;
|
|
this._shake = null;
|
|
this._resetHostTransform();
|
|
if (this._ctx && this._canvas) {
|
|
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
|
}
|
|
return this;
|
|
},
|
|
|
|
// ---- public FX API -----------------------------------------------------
|
|
attach: function (baseCanvas) {
|
|
if (baseCanvas) this._base = baseCanvas;
|
|
this._ensure();
|
|
return this;
|
|
},
|
|
|
|
syncTo: function (baseCanvas) {
|
|
if (baseCanvas) this._base = baseCanvas;
|
|
this._ensure();
|
|
return this;
|
|
},
|
|
|
|
// Warm torch light centered on the player cell (base-canvas pixel space).
|
|
renderLighting: function (playerScreenX, playerScreenY, cellPx, opts) {
|
|
if (typeof playerScreenX !== 'number' || typeof playerScreenY !== 'number') return this;
|
|
if (!isFinite(playerScreenX) || !isFinite(playerScreenY)) return this;
|
|
opts = opts || {};
|
|
var L = this._light;
|
|
L.x = playerScreenX;
|
|
L.y = playerScreenY;
|
|
L.cell = cellPx || L.cell || 32;
|
|
L.radius = opts.radius || L.radius || 6.5;
|
|
L.color = opts.color || null;
|
|
L.intensity = (typeof opts.intensity === 'number') ? opts.intensity : 1;
|
|
this._active = true;
|
|
if (!this._ensure()) return this; // no overlay -> no-op
|
|
this.start();
|
|
return this;
|
|
},
|
|
|
|
setAmbient: function (on) {
|
|
this._forceAmbient = !!on;
|
|
if (on) this.start();
|
|
return this;
|
|
},
|
|
|
|
sparkle: function (x, y, color) {
|
|
if (typeof x !== 'number' || typeof y !== 'number') return this;
|
|
if (!this._ensure()) return this;
|
|
var n = 5;
|
|
for (var i = 0; i < n; i++) {
|
|
var a = Math.random() * Math.PI * 2;
|
|
var sp = 10 + Math.random() * 40;
|
|
this._particles.push({
|
|
x: x, y: y,
|
|
vx: Math.cos(a) * sp,
|
|
vy: Math.sin(a) * sp - 20,
|
|
life: 0.5 + Math.random() * 0.4,
|
|
max: 0.9,
|
|
size: 6 + Math.random() * 8,
|
|
color: color || '#ffd700',
|
|
twinkle: true
|
|
});
|
|
}
|
|
this._cap();
|
|
this.start();
|
|
return this;
|
|
},
|
|
|
|
burst: function (x, y, color, count) {
|
|
if (typeof x !== 'number' || typeof y !== 'number') return this;
|
|
if (!this._ensure()) return this;
|
|
var n = count || 14;
|
|
for (var i = 0; i < n; i++) {
|
|
var a = Math.random() * Math.PI * 2;
|
|
var sp = 40 + Math.random() * 140;
|
|
this._particles.push({
|
|
x: x, y: y,
|
|
vx: Math.cos(a) * sp,
|
|
vy: Math.sin(a) * sp,
|
|
life: 0.45 + Math.random() * 0.55,
|
|
max: 1.0,
|
|
size: 8 + Math.random() * 12,
|
|
color: color || '#fbbf24',
|
|
twinkle: false
|
|
});
|
|
}
|
|
this._cap();
|
|
this.start();
|
|
return this;
|
|
},
|
|
|
|
flash: function (color, alpha, ms) {
|
|
if (!this._ensure()) return this;
|
|
this._flash = {
|
|
color: color || '#ffffff',
|
|
alpha: (typeof alpha === 'number') ? alpha : 0.5,
|
|
t: 0,
|
|
dur: Math.max(0.05, (ms || 220) / 1000)
|
|
};
|
|
this.start();
|
|
return this;
|
|
},
|
|
|
|
shake: function (intensity, ms) {
|
|
if (!this._ensure()) return this;
|
|
this._shake = {
|
|
mag: intensity || 6,
|
|
t: 0,
|
|
dur: Math.max(0.05, (ms || 320) / 1000)
|
|
};
|
|
this.start();
|
|
return this;
|
|
},
|
|
|
|
_cap: function () {
|
|
var maxP = 180;
|
|
if (this._particles.length > maxP) {
|
|
this._particles.splice(0, this._particles.length - maxP);
|
|
}
|
|
},
|
|
|
|
// ---- shake host transform ---------------------------------------------
|
|
_setHostTransform: function (dx, dy) {
|
|
if (!this._host) return;
|
|
if (this._hostOrig == null) this._hostOrig = this._host.style.transform || '';
|
|
this._host.style.transform = (this._hostOrig + ' translate(' + dx.toFixed(2) + 'px,' + dy.toFixed(2) + 'px)').trim();
|
|
},
|
|
|
|
_resetHostTransform: function () {
|
|
if (this._host && this._hostOrig != null) {
|
|
this._host.style.transform = this._hostOrig;
|
|
}
|
|
this._hostOrig = null;
|
|
},
|
|
|
|
// ---- animation loop ----------------------------------------------------
|
|
_frame: function () {
|
|
if (!this._running) return;
|
|
var ctx = this._ensure();
|
|
if (!ctx) { this._running = false; this._raf = 0; return; }
|
|
|
|
var t = now();
|
|
var dt = this._lastT ? (t - this._lastT) / 1000 : 0.016;
|
|
if (dt > 0.05) dt = 0.05; // clamp tab-switch gaps
|
|
this._lastT = t;
|
|
this._t += dt;
|
|
|
|
this._update(dt);
|
|
this._render(ctx);
|
|
|
|
if (this._busy()) {
|
|
this._raf = root.requestAnimationFrame(this._frameBound);
|
|
} else {
|
|
// Idle (game over / title, nothing animating): suspend the loop
|
|
// entirely: no lingering rAF, no leak. A future call restarts it.
|
|
this._running = false;
|
|
this._raf = 0;
|
|
this._active = false;
|
|
this._resetHostTransform();
|
|
ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
|
|
}
|
|
},
|
|
|
|
_update: function (dt) {
|
|
var w = this._canvas.width, h = this._canvas.height;
|
|
|
|
if (this._embers) {
|
|
for (var i = 0; i < this._embers.length; i++) {
|
|
var e = this._embers[i];
|
|
e.x += e.vx * dt;
|
|
e.y += e.vy * dt;
|
|
if (e.y < -10) { e.y = h + 10; e.x = Math.random() * w; }
|
|
if (e.x < -10) e.x = w + 10; else if (e.x > w + 10) e.x = -10;
|
|
}
|
|
}
|
|
|
|
for (var j = this._particles.length - 1; j >= 0; j--) {
|
|
var p = this._particles[j];
|
|
p.life -= dt;
|
|
if (p.life <= 0) { this._particles.splice(j, 1); continue; }
|
|
p.x += p.vx * dt;
|
|
p.y += p.vy * dt;
|
|
p.vx *= (1 - 1.6 * dt); // drag
|
|
p.vy += 60 * dt; // gentle gravity
|
|
p.vy *= (1 - 1.2 * dt);
|
|
}
|
|
|
|
if (this._flash) {
|
|
this._flash.t += dt;
|
|
if (this._flash.t >= this._flash.dur) this._flash = null;
|
|
}
|
|
|
|
if (this._shake) {
|
|
this._shake.t += dt;
|
|
if (this._shake.t >= this._shake.dur) {
|
|
this._shake = null;
|
|
this._resetHostTransform();
|
|
}
|
|
}
|
|
},
|
|
|
|
_render: function (ctx) {
|
|
var w = this._canvas.width, h = this._canvas.height;
|
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
ctx.globalAlpha = 1;
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
ctx.clearRect(0, 0, w, h);
|
|
|
|
// Screen shake: translate the whole host (base ROT canvas + overlays move
|
|
// together, stays perfectly aligned); decays to zero.
|
|
if (this._shake) {
|
|
var k = 1 - (this._shake.t / this._shake.dur);
|
|
var mag = this._shake.mag * k;
|
|
this._setHostTransform((Math.random() * 2 - 1) * mag, (Math.random() * 2 - 1) * mag);
|
|
}
|
|
|
|
var scene = this._sceneOn();
|
|
|
|
if (scene) {
|
|
// (a) warm torch light on the player cell, gently flickering
|
|
if (this._active && this._gameActive() && this._texLight) {
|
|
var L = this._light;
|
|
var cell = L.cell || 32;
|
|
var flick = 0.86 + Math.sin(this._t * 7.0) * 0.06 + Math.sin(this._t * 17.0) * 0.04 + Math.random() * 0.03;
|
|
var rad = (L.radius || 6.5) * cell * flick;
|
|
var a = clamp(0.42 * (L.intensity || 1) * flick, 0, 0.85);
|
|
var tex = L.color ? this._tintedMote(L.color) : this._texLight;
|
|
if (tex) {
|
|
ctx.globalCompositeOperation = 'lighter';
|
|
ctx.globalAlpha = a;
|
|
ctx.drawImage(tex, L.x - rad, L.y - rad, rad * 2, rad * 2);
|
|
}
|
|
}
|
|
|
|
// (c) ambient drifting-ember field (subtle, additive)
|
|
if (this._embers && this._texEmber) {
|
|
ctx.globalCompositeOperation = 'lighter';
|
|
for (var i = 0; i < this._embers.length; i++) {
|
|
var e = this._embers[i];
|
|
ctx.globalAlpha = e.baseA * (0.55 + 0.45 * Math.sin(this._t * 1.6 + e.ph));
|
|
ctx.drawImage(this._texEmber, e.x - e.size, e.y - e.size, e.size * 2, e.size * 2);
|
|
}
|
|
}
|
|
|
|
// (b) screen-edge vignette (normal blend, darkens toward the frame)
|
|
if (this._texVig) {
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
ctx.globalAlpha = 1;
|
|
ctx.drawImage(this._texVig, 0, 0, w, h);
|
|
}
|
|
}
|
|
|
|
// (d) transient one-shot particles (sparkle / burst), additive glow
|
|
if (this._particles.length) {
|
|
ctx.globalCompositeOperation = 'lighter';
|
|
for (var j = 0; j < this._particles.length; j++) {
|
|
var p = this._particles[j];
|
|
var lk = clamp(p.life / p.max, 0, 1);
|
|
var tw = p.twinkle ? (0.6 + 0.4 * Math.sin(this._t * 22 + p.x)) : 1;
|
|
ctx.globalAlpha = lk * tw;
|
|
var tex2 = this._tintedMote(p.color);
|
|
if (tex2) {
|
|
var s = p.size * (0.55 + 0.45 * lk);
|
|
ctx.drawImage(tex2, p.x - s, p.y - s, s * 2, s * 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
// (e) full-canvas color flash (normal blend, fades out)
|
|
if (this._flash) {
|
|
var fk = clamp(1 - (this._flash.t / this._flash.dur), 0, 1);
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
ctx.globalAlpha = clamp(this._flash.alpha * fk, 0, 1);
|
|
ctx.fillStyle = this._flash.color;
|
|
ctx.fillRect(0, 0, w, h);
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
|
ctx.globalCompositeOperation = 'source-over';
|
|
}
|
|
};
|
|
|
|
FX._frameBound = function () { FX._frame(); };
|
|
|
|
root.FX = FX;
|
|
if (typeof module !== 'undefined' && module.exports) module.exports = FX;
|
|
})(typeof window !== 'undefined' ? window : globalThis);
|