mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-28 02:28:34 +01:00
fix: resolve security/correctness review + AAA graphics pass
Backend (Tier 0/1/3): single-payout double-pay -> needs_review; admin-retry tx_hash guard; UNIQUE index on games.payment_id (migration 023) + atomic claim; reconnect no longer resets block-timeout; completeGame keys on user_id+dungeon_seed (not socket_id); token-gated /api/user/:socketId/* endpoints; payout min/max enforcement; orphaned-active-game boot recovery; sessionManager cleanup FK fix; XFF rightmost-hop; global uncaughtException/unhandledRejection guard; server binds 127.0.0.1; provably-fair client-seed (HMAC) + RNG [0,1) bound; difficulty preset ranges reach the digger; chat no longer leaks full socket.id; removed dangerous-keyword SQL theater. Match mode (Tier 2, still MATCH_ENABLED-gated): fixed match_queue/match_move crash paths + scheduler dead-code + leaked ceiling timer; pot collected before start with abort+refund; BigInt/basis-point fee math, capped payout; no-address winner claimable; winnerId==placement[0]; finalize schema + payout-first isolation; reconnect re-key with ownership; refunds on all cancel paths; real countdown; map/timer cleanup. +16 tests. Graphics: new overlay FX layer (torch light, vignette, embers, sparkle/burst/flash/shake, self-suspending rAF); gameplay juice; atmospheric screens; CSS design-token chrome with framed modals; richer Kenney tile renderer; imported Kenney dungeon/interior sheets + fx particles. Verification: 45 suites / 312 tests pass; 40 changed JS files parse; 20 server modules load clean; no regressions.
This commit is contained in:
@@ -112,11 +112,23 @@
|
||||
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
|
||||
// Default tile picks from the sheet metadata ( roguelikeSheet_meta.json).
|
||||
// The tile-picker overlay can override these per-user via localStorage.
|
||||
// floor/wall are ARRAYS of [col,row] variants (sheet cols = 57): TileRenderer
|
||||
// picks one deterministically per cell so a wall of stone doesn't read as a
|
||||
// single flat texture. Single-pair kinds (bar/table/features) draw as-is.
|
||||
tiles: {
|
||||
floor: [5, 2], // tile 119 — floor_wood
|
||||
wall: [13, 12], // tile 697 — stone wall
|
||||
bar: [28, 0], // tile 28 — counter
|
||||
table: [20, 3] // tile 191 — table
|
||||
// floor: sheet 119/120 (wood/stone) + 982-984 (planks)
|
||||
floor: [[5, 2], [6, 2], [13, 17], [14, 17], [15, 17]],
|
||||
floor2: [[6, 2], [13, 17], [15, 17]],
|
||||
// wall: sheet 697-701 + 868/869/871-874 (stone masonry)
|
||||
wall: [[13, 12], [14, 12], [15, 12], [16, 12], [17, 12],
|
||||
[13, 15], [14, 15], [16, 15], [17, 15], [18, 15], [19, 15]],
|
||||
bar: [28, 0], // tile 28 — counter
|
||||
table: [20, 3], // tile 191 — table
|
||||
// Dungeon features drawn as real tiles instead of monospace glyphs.
|
||||
entrance: [37, 9], // tile 550 — door
|
||||
exit: [37, 9], // tile 550 — door
|
||||
treasure: [42, 15], // tile 897 — item
|
||||
torch: [50, 10] // tile 620 — rubble / prop
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+267
-30
@@ -1,5 +1,8 @@
|
||||
// TileRenderer — draws a Scene as coloured tiles + entity sprites on a 2D canvas.
|
||||
// The default tier. Uses programmatic tiles for now; a real tileset/atlas can slot in here.
|
||||
// TileRenderer — draws a Scene as tiled sprites + entity sprites on a 2D canvas.
|
||||
// The default tier. Pulls tiles from the theme atlas (Kenney roguelikeSheet) when loaded,
|
||||
// with a flat-colour fallback for any unmapped kind. Adds torch-lit dungeon atmosphere:
|
||||
// per-cell tile variation, drop shadows, a warm torch light on the player + edge vignette,
|
||||
// and a lightweight requestAnimationFrame flicker/ember loop composited over the last scene.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
@@ -10,6 +13,50 @@
|
||||
return { x: 0, y: 1 };
|
||||
}
|
||||
|
||||
// Deterministic per-cell hash so a given (x,y) always maps to the same tile variant.
|
||||
function cellHash(x, y) {
|
||||
var h = (x * 73856093) ^ (y * 19349663);
|
||||
h = h % 100000;
|
||||
return h < 0 ? h + 100000 : h;
|
||||
}
|
||||
|
||||
// A tile coord is either a single [col,row] or an ARRAY of [col,row] variants.
|
||||
// Resolve to one [col,row] for this cell.
|
||||
function pickVariant(coord, x, y) {
|
||||
if (!coord) return null;
|
||||
if (Array.isArray(coord[0])) return coord[cellHash(x, y) % coord.length];
|
||||
return coord;
|
||||
}
|
||||
|
||||
// Dungeon lighting: darken a cell based on the scene's light grid (1 = lit, 0 = dark).
|
||||
function applyCellLight(ctx, scene, x, y, px, py, cell) {
|
||||
if (!scene.lightGrid || !scene.lightGrid[y] || scene.lightGrid[y][x] == null) return;
|
||||
var brightness = scene.lightGrid[y][x];
|
||||
if (brightness < 1) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,' + (1 - brightness) + ')';
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
}
|
||||
}
|
||||
|
||||
// The player/you entity that carries the torch (for the light composite + embers).
|
||||
function findPlayer(scene) {
|
||||
if (!scene || !scene.entities) return null;
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
if (e.kind === 'player' || e.you) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Real-tile coord for a dungeon feature entity, keyed off its glyph.
|
||||
function featureCoord(tmap, e) {
|
||||
var ch = e.char;
|
||||
if (ch === '<') return tmap.entrance;
|
||||
if (ch === '>') return tmap.exit;
|
||||
if (ch === '$' || ch === '$W' || ch === '$M') return tmap.treasure;
|
||||
return null;
|
||||
}
|
||||
|
||||
function TileRenderer(host, opts) {
|
||||
opts = opts || {};
|
||||
this.name = 'tiles';
|
||||
@@ -19,10 +66,15 @@
|
||||
this.canvas.className = 'rk-canvas';
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
host.appendChild(this.canvas);
|
||||
this._snap = null; // offscreen snapshot of the base scene (tiles + entities)
|
||||
this._lastScene = null;
|
||||
this._embers = [];
|
||||
this._raf = null;
|
||||
this._t = 0;
|
||||
}
|
||||
|
||||
TileRenderer.prototype.render = function (scene) {
|
||||
if (!scene) return;
|
||||
if (!scene || !this.ctx) return;
|
||||
var cell = this.cell, ctx = this.ctx;
|
||||
this.canvas.width = scene.cols * cell;
|
||||
this.canvas.height = scene.rows * cell;
|
||||
@@ -30,21 +82,35 @@
|
||||
ctx.fillStyle = scene.background || '#000';
|
||||
ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
var atlas = (window.RK && RK.themeAtlas) ? RK.themeAtlas() : null;
|
||||
var tmap = (window.RK && RK.tileMap) ? RK.tileMap() : {};
|
||||
var useAtlas = !!(atlas && atlas.ready);
|
||||
|
||||
if (!scene.isDungeon && window.RK && RK.roomReady && RK.roomReady()) {
|
||||
// A designed room (imported .tmx) is loaded — draw its layered tiles.
|
||||
RK.drawRoomCanvas(ctx, cell);
|
||||
} else {
|
||||
// Theme atlas tile if mapped, else the programmatic coloured tile.
|
||||
var atlas = (window.RK && RK.themeAtlas) ? RK.themeAtlas() : null;
|
||||
var tmap = (window.RK && RK.tileMap) ? RK.tileMap() : {};
|
||||
var useAtlas = atlas && atlas.ready;
|
||||
for (var y = 0; y < scene.rows; y++) {
|
||||
for (var x = 0; x < scene.cols; x++) {
|
||||
var kind = scene.grid[y][x];
|
||||
var px = x * cell, py = y * cell;
|
||||
var coord = useAtlas ? tmap[kind] : null;
|
||||
if (coord) { atlas.draw(ctx, coord[0], coord[1], px, py, cell); continue; }
|
||||
var def = scene.legend[kind] || { color: '#333' };
|
||||
var coord = useAtlas ? pickVariant(tmap[kind], x, y) : null;
|
||||
if (coord) {
|
||||
atlas.draw(ctx, coord[0], coord[1], px, py, cell);
|
||||
// Dark bottom-edge strip grounds a solid wall against the floor below.
|
||||
if (def.solid) {
|
||||
var strip = Math.max(2, cell * 0.16);
|
||||
var sg = ctx.createLinearGradient(0, py + cell - strip, 0, py + cell);
|
||||
sg.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
sg.addColorStop(1, 'rgba(0,0,0,0.42)');
|
||||
ctx.fillStyle = sg;
|
||||
ctx.fillRect(px, py + cell - strip, cell, strip);
|
||||
}
|
||||
applyCellLight(ctx, scene, x, y, px, py, cell);
|
||||
continue;
|
||||
}
|
||||
// Flat-colour fallback for any unmapped kind (or atlas not yet loaded).
|
||||
ctx.fillStyle = def.color;
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.25)';
|
||||
@@ -53,15 +119,10 @@
|
||||
if (def.solid) {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.06)';
|
||||
ctx.fillRect(px, py, cell, Math.max(1, cell * 0.14));
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.fillRect(px, py + cell - Math.max(1, cell * 0.14), cell, Math.max(1, cell * 0.14));
|
||||
}
|
||||
// Dungeon lighting: darken tiles based on the light grid.
|
||||
if (scene.lightGrid && scene.lightGrid[y] && scene.lightGrid[y][x] != null) {
|
||||
var brightness = scene.lightGrid[y][x];
|
||||
if (brightness < 1) {
|
||||
ctx.fillStyle = 'rgba(0,0,0,' + (1 - brightness) + ')';
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
}
|
||||
}
|
||||
applyCellLight(ctx, scene, x, y, px, py, cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,29 +130,45 @@
|
||||
var now = Date.now();
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
// Dungeon features (entrance, exit, treasure) — draw as colored glyphs.
|
||||
// Dungeon features (entrance / exit / treasure) — real tiles, glyph fallback.
|
||||
if (e.kind === 'feature') {
|
||||
var cx = e.x * cell + cell / 2, cy = e.y * cell + cell / 2;
|
||||
ctx.fillStyle = e.color || '#fff';
|
||||
ctx.font = (cell - 4) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(e.char || '?', cx, cy);
|
||||
var fpx = e.x * cell, fpy = e.y * cell;
|
||||
var fc = useAtlas ? pickVariant(featureCoord(tmap, e), e.x, e.y) : null;
|
||||
if (fc) {
|
||||
atlas.draw(ctx, fc[0], fc[1], fpx, fpy, cell);
|
||||
// Soft colour accent so entrance (green) / exit (amber) stay legible.
|
||||
if (e.color) {
|
||||
var acx = fpx + cell / 2, acy = fpy + cell / 2;
|
||||
var ag = ctx.createRadialGradient(acx, acy, 0, acx, acy, cell * 0.6);
|
||||
ag.addColorStop(0, hexToRgba(e.color, 0.32));
|
||||
ag.addColorStop(1, hexToRgba(e.color, 0));
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.fillStyle = ag;
|
||||
ctx.fillRect(fpx, fpy, cell, cell);
|
||||
ctx.restore();
|
||||
}
|
||||
} else {
|
||||
softGlyph(ctx, e.char || '?', e.color || '#fff', fpx + cell / 2, fpy + cell / 2, cell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Dungeon items.
|
||||
if (e.kind === 'item') {
|
||||
var ix = e.x * cell + cell / 2, iy = e.y * cell + cell / 2;
|
||||
ctx.fillStyle = e.color || '#fbbf24';
|
||||
ctx.font = (cell - 4) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('$', ix, iy);
|
||||
var ipx = e.x * cell, ipy = e.y * cell;
|
||||
var ic = useAtlas ? pickVariant(tmap.treasure, e.x, e.y) : null;
|
||||
drawEntityShadow(ctx, e.x * cell + cell / 2, ipy + cell * 0.86, cell * 0.3, cell * 0.12);
|
||||
if (ic) {
|
||||
atlas.draw(ctx, ic[0], ic[1], ipx, ipy, cell);
|
||||
} else {
|
||||
softGlyph(ctx, '$', e.color || '#fbbf24', ipx + cell / 2, ipy + cell / 2, cell);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Dungeon monster.
|
||||
if (e.kind === 'monster') {
|
||||
var mx = e.x * cell + cell / 2, my = e.y * cell + cell / 2, mr = cell * 0.34;
|
||||
drawEntityShadow(ctx, mx, e.y * cell + cell * 0.9, cell * 0.32, cell * 0.13);
|
||||
ctx.beginPath();
|
||||
ctx.arc(mx, my, mr, 0, Math.PI * 2);
|
||||
ctx.fillStyle = e.color || '#f85149';
|
||||
@@ -101,6 +178,8 @@
|
||||
ctx.stroke();
|
||||
continue;
|
||||
}
|
||||
// Character-like entity: soft ground shadow, then the sprite (or fallback circle).
|
||||
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.92, cell * 0.34, cell * 0.14);
|
||||
// Roguelike character sprite, or premium animated skin; else the fallback circle.
|
||||
if (window.RK && RK.avatarVisuals && RK.avatarVisuals.drawTopdownWorld) {
|
||||
var visual = RK.avatarVisuals.resolve(e.appearance || { avatar: e.avatar }, {
|
||||
@@ -142,12 +221,170 @@
|
||||
ctx.fillText(e.label, cx, cy - cell * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// 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._snapshot();
|
||||
this._ensureEmbers(scene);
|
||||
this._composite(scene);
|
||||
this._startLoop();
|
||||
};
|
||||
|
||||
// Soft drop shadow (an ellipse) under an entity's feet.
|
||||
function drawEntityShadow(ctx, cx, cy, rx, ry) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.33)';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// A softened glyph fallback (used only when the atlas tile is unavailable).
|
||||
function softGlyph(ctx, ch, color, cx, cy, cell) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = 0.85;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, cell * 0.3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||||
ctx.fill();
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold ' + Math.round(cell * 0.62) + 'px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(ch, cx, cy + 1);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function hexToRgba(hex, a) {
|
||||
hex = String(hex).replace('#', '');
|
||||
if (hex.length === 3) hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
|
||||
var n = parseInt(hex, 16);
|
||||
if (isNaN(n)) return 'rgba(255,255,255,' + a + ')';
|
||||
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + a + ')';
|
||||
}
|
||||
|
||||
// Copy the freshly-drawn base scene into an offscreen canvas so the flicker loop can
|
||||
// restore it each frame without re-running the (expensive) tile + entity passes.
|
||||
TileRenderer.prototype._snapshot = function () {
|
||||
if (!this.canvas) return;
|
||||
if (!this._snap) this._snap = document.createElement('canvas');
|
||||
if (this._snap.width !== this.canvas.width) this._snap.width = this.canvas.width;
|
||||
if (this._snap.height !== this.canvas.height) this._snap.height = this.canvas.height;
|
||||
var sctx = this._snap.getContext('2d');
|
||||
sctx.clearRect(0, 0, this._snap.width, this._snap.height);
|
||||
sctx.drawImage(this.canvas, 0, 0);
|
||||
};
|
||||
|
||||
TileRenderer.prototype._ensureEmbers = function (scene) {
|
||||
var p = findPlayer(scene);
|
||||
if (!p) { this._embers = []; return; }
|
||||
if (this._embers.length) return;
|
||||
var cell = this.cell;
|
||||
var pcx = p.x * cell + cell / 2, pcy = p.y * cell + cell / 2;
|
||||
for (var i = 0; i < 10; i++) this._embers.push(this._spawnEmber(pcx, pcy, cell, true));
|
||||
};
|
||||
|
||||
TileRenderer.prototype._spawnEmber = function (pcx, pcy, cell, seed) {
|
||||
var max = 60 + Math.random() * 60;
|
||||
return {
|
||||
x: pcx + (Math.random() - 0.5) * cell * 2.2,
|
||||
y: pcy + (Math.random() - 0.5) * cell * 1.6,
|
||||
vx: (Math.random() - 0.5) * 0.25,
|
||||
vy: -(0.15 + Math.random() * 0.35),
|
||||
size: cell * (0.05 + Math.random() * 0.06),
|
||||
life: seed ? Math.random() * max : max,
|
||||
max: max
|
||||
};
|
||||
};
|
||||
|
||||
TileRenderer.prototype._advanceEmbers = function () {
|
||||
var scene = this._lastScene;
|
||||
var p = findPlayer(scene);
|
||||
if (!p) return;
|
||||
var cell = this.cell;
|
||||
var pcx = p.x * cell + cell / 2, pcy = p.y * cell + cell / 2;
|
||||
for (var i = 0; i < this._embers.length; i++) {
|
||||
var m = this._embers[i];
|
||||
m.x += m.vx;
|
||||
m.y += m.vy;
|
||||
m.life -= 1;
|
||||
if (m.life <= 0) this._embers[i] = this._spawnEmber(pcx, pcy, cell, false);
|
||||
}
|
||||
};
|
||||
|
||||
// Warm torch light on the player cell + drifting embers + an edge vignette. Ported from
|
||||
// the FancyRenderer recipe to flat 2D canvas (no Pixi, no shaders).
|
||||
TileRenderer.prototype._composite = function (scene) {
|
||||
var ctx = this.ctx;
|
||||
if (!ctx) return;
|
||||
var w = this.canvas.width, h = this.canvas.height, cell = this.cell;
|
||||
var p = findPlayer(scene);
|
||||
if (p) {
|
||||
var cx = p.x * cell + cell / 2, cy = p.y * cell + cell / 2;
|
||||
var flick = 1 + Math.sin(this._t * 0.18) * 0.08 + Math.sin(this._t * 0.5 + 1.3) * 0.04;
|
||||
var rad = cell * 4.4 * flick;
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, rad);
|
||||
g.addColorStop(0, 'rgba(255,190,110,0.42)');
|
||||
g.addColorStop(0.5, 'rgba(255,150,70,0.14)');
|
||||
g.addColorStop(1, 'rgba(255,150,70,0)');
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'lighter';
|
||||
ctx.fillStyle = g;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, rad, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Embers.
|
||||
for (var i = 0; i < this._embers.length; i++) {
|
||||
var m = this._embers[i];
|
||||
var a = Math.max(0, Math.min(1, m.life / m.max)) * 0.6;
|
||||
var eg = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, m.size * 3);
|
||||
eg.addColorStop(0, 'rgba(255,220,150,' + a + ')');
|
||||
eg.addColorStop(1, 'rgba(255,150,60,0)');
|
||||
ctx.fillStyle = eg;
|
||||
ctx.beginPath();
|
||||
ctx.arc(m.x, m.y, m.size * 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
// Edge vignette (normal blend, darkens the borders).
|
||||
var vig = ctx.createRadialGradient(
|
||||
w / 2, h / 2, Math.min(w, h) * 0.34,
|
||||
w / 2, h / 2, Math.max(w, h) * 0.72
|
||||
);
|
||||
vig.addColorStop(0, 'rgba(0,0,0,0)');
|
||||
vig.addColorStop(1, 'rgba(0,0,0,0.55)');
|
||||
ctx.save();
|
||||
ctx.fillStyle = vig;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
TileRenderer.prototype._startLoop = function () {
|
||||
if (this._raf != null) return;
|
||||
var self = this;
|
||||
function loop() {
|
||||
if (!self.ctx || !self._snap) { self._raf = null; return; }
|
||||
self._t += 1;
|
||||
self._advanceEmbers();
|
||||
self.ctx.clearRect(0, 0, self.canvas.width, self.canvas.height);
|
||||
self.ctx.drawImage(self._snap, 0, 0);
|
||||
self._composite(self._lastScene);
|
||||
self._raf = root.requestAnimationFrame(loop);
|
||||
}
|
||||
this._raf = root.requestAnimationFrame(loop);
|
||||
};
|
||||
|
||||
TileRenderer.prototype.destroy = function () {
|
||||
if (this._raf != null) { root.cancelAnimationFrame(this._raf); this._raf = null; }
|
||||
if (this.canvas && this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this._snap = null;
|
||||
this._lastScene = null;
|
||||
this._embers = [];
|
||||
};
|
||||
|
||||
root.RK = root.RK || {};
|
||||
|
||||
Reference in New Issue
Block a user