mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 15:48:33 +02: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:
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
+26
-21
@@ -69,6 +69,11 @@
|
||||
<script src="js/display/waitingScreenAnimator.js"></script>
|
||||
<script src="js/display/screenManager.js"></script>
|
||||
<script src="js/display/renderEngine.js"></script>
|
||||
<!-- Additive FX overlay (dependency-free window.FX; created by the fx-core track).
|
||||
Loaded BEFORE gameState.js / game.js / singlePlayerAvatar.js so their guarded
|
||||
`if (window.FX)` hooks can fire. All methods are no-ops when no overlay canvas
|
||||
is present, so match.html / tavern.html are unaffected. -->
|
||||
<script src="js/display/fxOverlay.js"></script>
|
||||
<script src="js/core/gameState.js"></script>
|
||||
<script src="js/core/game.js"></script>
|
||||
<script src="js/render/atlas.js"></script>
|
||||
@@ -110,41 +115,41 @@
|
||||
<div id="publicStats" style="display:none; margin-top:4px; font-size:11px; color:#9cf; font-family:monospace; line-height:1.4;"></div>
|
||||
<div id="liveCta" style="display:none; margin-top:6px; cursor:pointer; background:rgba(80,0,0,0.55); border:1px solid #f33; border-radius:4px; padding:5px 10px; font-size:12px; font-family:monospace; color:#ff8a8a; font-weight:bold;" title="Watch a live game"></div>
|
||||
<div id="creditsDisplay" style="display:none; margin-top:4px; font-size:13px; font-family:monospace; color:#0f0; background:#001a00; padding:4px 8px; border:1px solid #0a0; border-radius:4px; font-weight:bold;"></div>
|
||||
<button id="startButton" style="background: #0a5c0a; color: #ff0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; display: none;">
|
||||
<button id="startButton" class="ctrl-btn ctrl-btn--start" style="display: none;">
|
||||
🎮 <span class="lbl-full">START GAME</span><span class="lbl-short">START</span>
|
||||
</button>
|
||||
<button id="watchGamesButton" style="background: #053655; color: #0af; border: 2px solid #0af; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
|
||||
<button id="watchGamesButton" class="ctrl-btn ctrl-btn--watch">
|
||||
👁️ <span class="lbl-full">WATCH GAMES</span><span class="lbl-short">WATCH</span>
|
||||
</button>
|
||||
<button id="tavernButton" style="background: #2a0a3a; color: #d8b4fe; border: 2px solid #a855f7; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;" onclick="window.location.href='/tavern'">
|
||||
<button id="tavernButton" class="ctrl-btn ctrl-btn--tavern" style="display: none;" onclick="window.location.href='/tavern'">
|
||||
🍺 <span class="lbl-full">TAVERN</span><span class="lbl-short">TAVERN</span>
|
||||
</button>
|
||||
<button id="characterButton" style="background: #163044; color: #a7d8ff; border: 2px solid #5aa7d8; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px;">
|
||||
<button id="characterButton" class="ctrl-btn ctrl-btn--character">
|
||||
🧍 <span id="characterButtonLabel" class="lbl-full">CHARACTER</span><span class="lbl-short">CHAR</span>
|
||||
</button>
|
||||
<button id="shopButton" style="background: #004400; color: #0f0; border: 2px solid #0f0; padding: 8px 16px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
<button id="shopButton" class="ctrl-btn ctrl-btn--shop" style="display: none;">
|
||||
🛒 SHOP
|
||||
</button>
|
||||
<button id="animationToggleButton" style="background: #5c5c0a; color: #ff0; border: 2px solid #aa0; padding: 6px 12px; font-weight: bold; cursor: pointer; margin-top: 5px; margin-left: 10px; display: none;">
|
||||
<button id="animationToggleButton" class="ctrl-btn ctrl-btn--anim" style="display: none;">
|
||||
🎬 DISABLE ANIMATION
|
||||
</button>
|
||||
<div style="margin-top:6px; font-size:11px; color:#ccc; display:flex; align-items:center; gap:4px;">
|
||||
<label style="cursor:pointer; display:flex; align-items:center; gap:4px;">
|
||||
<input type="checkbox" id="audioAlertsToggle" style="cursor:pointer;"> Audio Alerts
|
||||
<div class="hud-toolbar">
|
||||
<label>
|
||||
<input type="checkbox" id="audioAlertsToggle"> Audio Alerts
|
||||
</label>
|
||||
<button id="helpButton" style="margin-left:auto; background:#2d2d00; color:#ffd700; border:1px solid #ffd700; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="How to play and payment information">❓ Help</button>
|
||||
<button id="historyButton" style="background:#1a1a4d; color:#c4b5fd; border:1px solid #8b5cf6; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="View your payment and payout history">📜 History</button>
|
||||
<button id="leaderboardButton" style="background:#4a2800; color:#fbbf24; border:1px solid #f59e0b; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="View high scores">🏆 Scores</button>
|
||||
<button id="manageAddressButton" style="background:#053655; color:#0ff; border:1px solid #0ff; padding:4px 10px; font-size:11px; cursor:pointer; border-radius:3px;" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
|
||||
<span id="sessionWarning" style="display:none; color:#f59e0b; font-size:10px; margin-left:6px;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
|
||||
<button id="helpButton" class="hud-chip hud-chip--help hud-chip--push" title="How to play and payment information">❓ Help</button>
|
||||
<button id="historyButton" class="hud-chip hud-chip--history" title="View your payment and payout history">📜 History</button>
|
||||
<button id="leaderboardButton" class="hud-chip hud-chip--scores" title="View high scores">🏆 Scores</button>
|
||||
<button id="manageAddressButton" class="hud-chip hud-chip--address" title="Your payout address and credits are tied to a browser session token. Clearing cookies/localStorage or using incognito mode will lose your session.">⚙️ Manage Payout Address</button>
|
||||
<span id="sessionWarning" style="display:none;" title="Private browsing or localStorage blocked - your session won't persist">⚠️ Session not saved</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment System UI -->
|
||||
<div id="payment-ui" class="payment-section" style="display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2000; background: rgba(0,20,0,0.95); border: 2px solid #0f0; padding: 20px; color: #0f0; min-width: 400px; max-width: 500px; box-shadow: 0 0 20px rgba(0,255,0,0.2);">
|
||||
<div class="payment-header">
|
||||
<div id="payment-ui" class="payment-section dungeon-modal" style="display: none;">
|
||||
<div class="payment-header dm-head">
|
||||
<strong>🎮 Choose How to Play</strong>
|
||||
<button id="close-payment" style="float: right; background: #500; color: #f00; border: 1px solid #f00; padding: 2px 6px; cursor: pointer;">✕</button>
|
||||
<button id="close-payment" class="dm-close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic game mode options populated by PaymentUI.js -->
|
||||
@@ -173,10 +178,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Transaction History Panel -->
|
||||
<div id="history-panel" style="display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2000; background: rgba(10,10,30,0.98); border: 2px solid #8b5cf6; padding: 20px; color: #e0e0e0; min-width: 500px; max-width: 600px; max-height: 80vh; overflow-y: auto; box-shadow: 0 0 20px rgba(139,92,246,0.3); border-radius: 8px;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px; border-bottom:1px solid #444; padding-bottom:10px;">
|
||||
<strong style="font-size:1.2em; color:#c4b5fd;">📜 Transaction History</strong>
|
||||
<button id="close-history" style="background: #500; color: #f00; border: 1px solid #f00; padding: 4px 8px; cursor: pointer; border-radius:3px;">✕</button>
|
||||
<div id="history-panel" class="dungeon-modal" style="display: none; max-width: 600px;">
|
||||
<div class="dm-head">
|
||||
<strong style="color:#c4b5fd;">📜 Transaction History</strong>
|
||||
<button id="close-history" class="dm-close">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Section -->
|
||||
|
||||
+190
-2
@@ -124,6 +124,11 @@ var Game = {
|
||||
}
|
||||
|
||||
this._drawGameScreen();
|
||||
|
||||
// JUICE: kick off the FX overlay (ambient embers + flicker + particle loop).
|
||||
// Purely additive; all calls are no-ops if the FX overlay module is absent.
|
||||
this._fxStart();
|
||||
|
||||
return true;
|
||||
|
||||
} catch (err) {
|
||||
@@ -144,12 +149,30 @@ var Game = {
|
||||
|
||||
// Update game state with new data from server
|
||||
updateGameState: function(data) {
|
||||
// JUICE: snapshot treasure/item count so we can detect a server-driven pickup below.
|
||||
const prevTreasureCount = (window.FX) ? this._fxTreasureCells().length : 0;
|
||||
|
||||
const needsRedraw = GameState.updateGameState(data);
|
||||
|
||||
|
||||
// Redraw the game screen if any relevant data changed
|
||||
if (needsRedraw) {
|
||||
this._drawGameScreen();
|
||||
}
|
||||
|
||||
// JUICE: react to server-driven changes (pickups, proximity) with FX.
|
||||
if (window.FX && needsRedraw) {
|
||||
try {
|
||||
const newTreasureCount = this._fxTreasureCells().length;
|
||||
if (newTreasureCount < prevTreasureCount) {
|
||||
// A treasure/item vanished near us -> gold burst (pickup).
|
||||
const pp = this._playerScreenPixel();
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 28);
|
||||
} else {
|
||||
// Otherwise run proximity effects for server-authoritative moves.
|
||||
this._fxOnPlayerMoved();
|
||||
}
|
||||
} catch (e) { /* FX is best-effort; never break gameplay */ }
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy alias (some handlers referenced updateGame previously)
|
||||
@@ -172,6 +195,29 @@ var Game = {
|
||||
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('other');
|
||||
}
|
||||
|
||||
// JUICE: celebratory or fatal FX on the outcome. Loop keeps running so the
|
||||
// burst/shake actually renders; it is torn down on return-to-title (below).
|
||||
if (window.FX) {
|
||||
try {
|
||||
const pp = this._playerScreenPixel();
|
||||
const escaped = !!(data && data.reason === 'escaped');
|
||||
if (escaped) {
|
||||
// Win: gold burst + brief bright flash.
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 40);
|
||||
window.FX.flash('rgba(255,215,0,1)', 0.35, 400);
|
||||
if (data && data.treasure) {
|
||||
window.FX.burst(pp.x, pp.y, '#fbbf24', 24);
|
||||
}
|
||||
} else {
|
||||
// Loss (monster / timeout / other): shake + red flash.
|
||||
window.FX.shake(12, 500);
|
||||
window.FX.flash('rgba(255,0,0,1)', 0.4, 450);
|
||||
}
|
||||
// Gameplay is over: quiet the ambient torch embers.
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
} catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
||||
// After short delay, allow user to return to title with Enter/start
|
||||
const score = data && typeof data.score === 'number' ? data.score : 0;
|
||||
const treasure = !!(data && data.treasure);
|
||||
@@ -208,6 +254,7 @@ var Game = {
|
||||
this._autoReturnTimer = setTimeout(() => {
|
||||
if (this._awaitingRestart) {
|
||||
this._awaitingRestart = false;
|
||||
this._fxStop(); // JUICE: tear down FX loop so it never leaks
|
||||
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
}
|
||||
@@ -222,6 +269,7 @@ var Game = {
|
||||
if (e.key === 'Enter') {
|
||||
this._awaitingRestart = false;
|
||||
clearTimeout(this._autoReturnTimer);
|
||||
this._fxStop(); // JUICE: tear down FX loop so it never leaks
|
||||
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
}
|
||||
@@ -242,8 +290,14 @@ var Game = {
|
||||
_drawGameScreen: function() {
|
||||
if (!this._ensureDisplay()) return;
|
||||
const gameState = GameState.getGameStateForRender();
|
||||
|
||||
|
||||
RenderEngine.drawGameScreen(gameState);
|
||||
|
||||
// JUICE: keep the FX overlay aligned to the base canvas and feed it the
|
||||
// player's screen-space position so its torch lighting tracks the camera.
|
||||
if (window.FX) {
|
||||
try { this._fxSyncLighting(); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
},
|
||||
|
||||
// Screen drawing methods - delegate to ScreenManager
|
||||
@@ -296,6 +350,140 @@ var Game = {
|
||||
const moved = GameState.movePlayer(dx, dy, this._screenWidth, this._screenHeight);
|
||||
if (moved) {
|
||||
this._drawGameScreen();
|
||||
// JUICE: sparkle near treasure, gold burst on pickup, red edge pulse near monsters.
|
||||
if (window.FX) {
|
||||
try { this._fxOnPlayerMoved(); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// JUICE / FX helpers. These ONLY call the window.FX overlay API (GFX1);
|
||||
// they never define it. Every call is guarded so the game stays fully
|
||||
// functional when the FX module is absent (match.html / tavern.html).
|
||||
// Coordinates are computed with the SAME camera-centered mapping the
|
||||
// renderer uses (the player is always drawn at the screen center).
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The ROT.js display container is the base canvas FX layers above.
|
||||
_baseCanvas: function() {
|
||||
if (typeof DisplayManager === 'undefined' || !DisplayManager.getDisplay) return null;
|
||||
var display = DisplayManager.getDisplay();
|
||||
return (display && display.getContainer) ? display.getContainer() : null;
|
||||
},
|
||||
|
||||
// Convert a world cell -> center pixel in base-canvas space.
|
||||
_cellToScreenPixel: function(wx, wy) {
|
||||
var cell = (window.options && window.options.tileWidth) ? window.options.tileWidth : 32;
|
||||
var centerX = Math.floor(this._screenWidth / 2);
|
||||
var centerY = Math.floor(this._screenHeight / 2);
|
||||
var player = (GameState && GameState._player) ? GameState._player : { x: wx, y: wy };
|
||||
var sx = wx - player.x + centerX;
|
||||
var sy = wy - player.y + centerY;
|
||||
return { x: sx * cell + cell / 2, y: sy * cell + cell / 2, cell: cell };
|
||||
},
|
||||
|
||||
// The player is camera-centered, so this is always the center of the view.
|
||||
_playerScreenPixel: function() {
|
||||
var p = (GameState && GameState._player) ? GameState._player : { x: 0, y: 0 };
|
||||
return this._cellToScreenPixel(p.x, p.y);
|
||||
},
|
||||
|
||||
// Chebyshev (king-move) distance — matches the grid feel.
|
||||
_fxDist: function(ax, ay, bx, by) {
|
||||
return Math.max(Math.abs(ax - bx), Math.abs(ay - by));
|
||||
},
|
||||
|
||||
// Collect every treasure/item world cell currently known.
|
||||
_fxTreasureCells: function() {
|
||||
var cells = [];
|
||||
if (typeof GameState === 'undefined') return cells;
|
||||
var t = GameState._treasure;
|
||||
if (t && typeof t[0] === 'number' && typeof t[1] === 'number') {
|
||||
cells.push({ x: t[0], y: t[1] });
|
||||
}
|
||||
var items = GameState._items;
|
||||
if (items) {
|
||||
for (var k in items) {
|
||||
if (items.hasOwnProperty(k)) {
|
||||
var it = items[k];
|
||||
if (it && typeof it.x === 'number' && typeof it.y === 'number') {
|
||||
cells.push({ x: it.x, y: it.y });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
},
|
||||
|
||||
// Fire proximity/pickup FX for the player's current position.
|
||||
_fxOnPlayerMoved: function() {
|
||||
if (!window.FX) return;
|
||||
var player = (GameState && GameState._player) ? GameState._player : null;
|
||||
if (!player) return;
|
||||
|
||||
var pp = this._playerScreenPixel();
|
||||
|
||||
// Treasure proximity: on-cell => gold burst, nearby => sparkle.
|
||||
var cells = this._fxTreasureCells();
|
||||
var onTreasure = false, nearTreasure = false;
|
||||
for (var i = 0; i < cells.length; i++) {
|
||||
var d = this._fxDist(player.x, player.y, cells[i].x, cells[i].y);
|
||||
if (d === 0) onTreasure = true;
|
||||
else if (d <= 2) nearTreasure = true;
|
||||
}
|
||||
if (onTreasure && window.FX.burst) {
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 24);
|
||||
} else if (nearTreasure && window.FX.sparkle) {
|
||||
window.FX.sparkle(pp.x, pp.y, '#fbbf24');
|
||||
}
|
||||
|
||||
// Monster proximity: throttled red edge pulse, stronger the closer it is.
|
||||
var m = GameState._monster;
|
||||
if (m && typeof m.x === 'number' && typeof m.y === 'number' && window.FX.flash) {
|
||||
var md = this._fxDist(player.x, player.y, m.x, m.y);
|
||||
if (md <= 3) {
|
||||
var now = Date.now();
|
||||
if (!this._fxLastMonsterFlash || (now - this._fxLastMonsterFlash) > 550) {
|
||||
this._fxLastMonsterFlash = now;
|
||||
var alpha = (md <= 1) ? 0.22 : (md <= 2 ? 0.14 : 0.09);
|
||||
window.FX.flash('rgba(255,60,60,1)', alpha, 260);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Attach + start the FX loop for a fresh game.
|
||||
_fxStart: function() {
|
||||
if (!window.FX) return;
|
||||
this._fxLastMonsterFlash = 0;
|
||||
try {
|
||||
var base = this._baseCanvas();
|
||||
if (base && window.FX.attach) window.FX.attach(base);
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(true);
|
||||
if (window.FX.start) window.FX.start();
|
||||
} catch (e) { /* best-effort */ }
|
||||
},
|
||||
|
||||
// Stop + clear the FX loop so requestAnimationFrame never leaks.
|
||||
_fxStop: function() {
|
||||
if (!window.FX) return;
|
||||
try {
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
if (window.FX.stop) window.FX.stop();
|
||||
if (window.FX.clear) window.FX.clear();
|
||||
} catch (e) { /* best-effort */ }
|
||||
},
|
||||
|
||||
// Keep the overlay aligned and feed it the camera-centered player position.
|
||||
_fxSyncLighting: function() {
|
||||
if (!window.FX) return;
|
||||
var base = this._baseCanvas();
|
||||
if (!base) return;
|
||||
if (window.FX.syncTo) window.FX.syncTo(base);
|
||||
if (window.FX.renderLighting && GameState && GameState._player) {
|
||||
var pp = this._playerScreenPixel();
|
||||
window.FX.renderLighting(pp.x, pp.y, pp.cell, { torches: GameState._torches || [] });
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -177,12 +177,32 @@
|
||||
this.clearOverlay();
|
||||
var player = gameState.player;
|
||||
var facing = this._updateFacing(player);
|
||||
return root.RK.avatarVisuals.drawTopdownWorld(ctx, visual, {
|
||||
var drew = root.RK.avatarVisuals.drawTopdownWorld(ctx, visual, {
|
||||
id: 'single-player',
|
||||
x: player.x,
|
||||
y: player.y,
|
||||
facing: facing
|
||||
}, viewport, { onReady: requestRedraw });
|
||||
// After the avatar is drawn, sync the overlay FX layer to the same base
|
||||
// canvas and let its warm torch light track the player cell. Fully guarded:
|
||||
// no-op when the FX module is absent.
|
||||
this._syncFX(viewport);
|
||||
return drew;
|
||||
},
|
||||
|
||||
// Attach/sync the FX overlay to the same base canvas used by the avatar overlay
|
||||
// (mirrors _syncOverlay's sizing) and drive the torch light at the player cell.
|
||||
_syncFX: function (viewport) {
|
||||
if (!root.FX || !viewport) return;
|
||||
var base = this._baseCanvas();
|
||||
if (!base) return;
|
||||
try {
|
||||
root.FX.attach(base);
|
||||
var cell = viewport.cell || (root.options && root.options.tileWidth) || 32;
|
||||
var px = viewport.screenX * cell + cell / 2;
|
||||
var py = viewport.screenY * cell + cell / 2;
|
||||
root.FX.renderLighting(px, py, cell);
|
||||
} catch (_) { /* FX is purely additive — never let it break the avatar */ }
|
||||
},
|
||||
|
||||
drawLegendIcon: function (tileX, tileY, opts) {
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
// 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);
|
||||
@@ -272,6 +272,18 @@ var RenderEngine = {
|
||||
}
|
||||
}
|
||||
|
||||
// Drive the overlay FX torch light on the player, who is always camera-centered
|
||||
// at (centerX, centerY). Coordinates are in base-canvas pixel space (tile * cell),
|
||||
// matching the avatar overlay convention. Fully guarded + additive only; the ROT
|
||||
// display draw above is untouched. Works whether or not the fancy avatar rendered.
|
||||
if (window.FX && window.FX.renderLighting) {
|
||||
try {
|
||||
var _cellW = (window.options && window.options.tileWidth) || 32;
|
||||
var _cellH = (window.options && window.options.tileHeight) || _cellW;
|
||||
window.FX.renderLighting(centerX * _cellW + _cellW / 2, centerY * _cellH + _cellH / 2, _cellW);
|
||||
} catch (_) { /* FX is purely cosmetic — never block the dungeon render */ }
|
||||
}
|
||||
|
||||
// Render message at bottom if present
|
||||
if (message) {
|
||||
ScreenManager.drawCenteredText(this._screenHeight - 1, message);
|
||||
|
||||
@@ -7,6 +7,7 @@ var ScreenManager = {
|
||||
_waitingForBlock: false,
|
||||
_isShowingWaitingScreen: false, // Track if waiting screen is active
|
||||
_cryptoType: 'WOW', // Default to WOW, updated on game mode info
|
||||
_ambientFXOn: false, // GFX3: FX overlay ambient state (guarded, no-op unless window.FX)
|
||||
|
||||
// Get the game title based on crypto type
|
||||
getGameTitle: function() {
|
||||
@@ -31,6 +32,7 @@ var ScreenManager = {
|
||||
this._isShowingWaitingScreen = false;
|
||||
this._updateInterval = null; // Initialize update interval tracker
|
||||
this._cryptoType = 'WOW';
|
||||
this._ambientFXOn = false; // GFX3: reset FX overlay ambient state
|
||||
|
||||
// Initialize the waiting screen animator
|
||||
if (typeof WaitingScreenAnimator !== 'undefined') {
|
||||
@@ -38,6 +40,53 @@ var ScreenManager = {
|
||||
}
|
||||
},
|
||||
|
||||
// ===== GFX3: FX overlay helpers (additive, always guarded) =====
|
||||
_fxBaseCanvas: function() {
|
||||
try {
|
||||
var d = (typeof DisplayManager !== 'undefined' && DisplayManager.getDisplay)
|
||||
? DisplayManager.getDisplay() : null;
|
||||
return (d && d.getContainer) ? d.getContainer() : null;
|
||||
} catch (e) { return null; }
|
||||
},
|
||||
|
||||
// Subtle vignette + slow ambient embers behind the teletype title.
|
||||
_startAmbientFX: function() {
|
||||
if (!window.FX) return;
|
||||
if (this._ambientFXOn) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas) return;
|
||||
try {
|
||||
if (window.FX.attach) window.FX.attach(canvas);
|
||||
if (window.FX.syncTo) window.FX.syncTo(canvas);
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(true);
|
||||
if (window.FX.start) window.FX.start();
|
||||
this._ambientFXOn = true;
|
||||
} catch (e) { /* additive: never break the screen */ }
|
||||
},
|
||||
|
||||
_stopAmbientFX: function() {
|
||||
this._ambientFXOn = false;
|
||||
if (!window.FX) return;
|
||||
try {
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
if (window.FX.stop) window.FX.stop();
|
||||
if (window.FX.clear) window.FX.clear();
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
// One-shot celebratory/somber flash as an end screen appears.
|
||||
_screenFlash: function(color, alpha, ms) {
|
||||
if (!window.FX) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas) return;
|
||||
try {
|
||||
if (window.FX.attach) window.FX.attach(canvas);
|
||||
if (window.FX.syncTo) window.FX.syncTo(canvas);
|
||||
if (window.FX.start) window.FX.start();
|
||||
if (window.FX.flash) window.FX.flash(color, alpha, ms);
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
drawText: function(x, y, text) {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
const display = DisplayManager.getDisplay();
|
||||
@@ -87,7 +136,10 @@ var ScreenManager = {
|
||||
|
||||
// Draw the border
|
||||
this.drawBorder();
|
||||
|
||||
|
||||
// GFX3: subtle vignette + slow ambient embers behind the teletype title
|
||||
this._startAmbientFX();
|
||||
|
||||
// Draw content - use dynamic title based on crypto type
|
||||
let y = 3;
|
||||
this.drawCenteredText(y, this.getGameTitle());
|
||||
@@ -242,6 +294,18 @@ var ScreenManager = {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
// GFX3: brief celebratory gold flash + burst as the win screen appears
|
||||
if (window.FX) {
|
||||
this._stopAmbientFX();
|
||||
this._screenFlash('#ffd700', 0.45, 700);
|
||||
var winCanvas = this._fxBaseCanvas();
|
||||
if (winCanvas && window.FX.burst) {
|
||||
try {
|
||||
window.FX.burst(winCanvas.width / 2, winCanvas.height / 2, '#ffd700', 24);
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
let y = Math.floor(this._screenHeight / 3);
|
||||
this.drawCenteredText(y, "CONGRATULATIONS!");
|
||||
|
||||
@@ -257,6 +321,12 @@ var ScreenManager = {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
// GFX3: brief somber red flash as the lose screen appears
|
||||
if (window.FX) {
|
||||
this._stopAmbientFX();
|
||||
this._screenFlash('#ff2a2a', 0.4, 700);
|
||||
}
|
||||
|
||||
let y = Math.floor(this._screenHeight / 3);
|
||||
this.drawCenteredText(y, "YOU HAVE PERISHED");
|
||||
|
||||
@@ -364,6 +434,9 @@ var ScreenManager = {
|
||||
if (typeof WaitingScreenAnimator !== 'undefined') {
|
||||
WaitingScreenAnimator.stopAnimation();
|
||||
}
|
||||
// GFX3: waiting animator tears down the shared FX overlay; clear our flag
|
||||
// so ambient FX can restart when the welcome screen is shown again.
|
||||
this._ambientFXOn = false;
|
||||
this.hideAnimationButton();
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
@@ -3,6 +3,9 @@ var WaitingScreenAnimator = {
|
||||
_animationEnabled: true,
|
||||
_waitingAnimationInterval: null,
|
||||
_animating: false,
|
||||
// GFX3: FX overlay ambient state (additive; no-op unless window.FX exists)
|
||||
_fxAmbientOn: false,
|
||||
_fxTick: 0,
|
||||
_waitingAnimation: {
|
||||
frame: 0,
|
||||
playerX: 8,
|
||||
@@ -82,13 +85,74 @@ var WaitingScreenAnimator = {
|
||||
display.draw(startX + roomWidth - 1, roomY, GameTiles.getFloorTile(true), "white", "transparent"); // Floor variation
|
||||
},
|
||||
|
||||
// ===== GFX3: FX overlay helpers (additive, always guarded) =====
|
||||
_fxBaseCanvas: function() {
|
||||
try {
|
||||
var d = (typeof DisplayManager !== 'undefined' && DisplayManager.getDisplay)
|
||||
? DisplayManager.getDisplay() : null;
|
||||
return (d && d.getContainer) ? d.getContainer() : null;
|
||||
} catch (e) { return null; }
|
||||
},
|
||||
|
||||
// Turn on the torchlight gradient + drifting embers overlay behind the
|
||||
// scripted char-cell waiting animation. No-op unless window.FX is present.
|
||||
_startAmbientFX: function() {
|
||||
if (!window.FX) return;
|
||||
if (this._fxAmbientOn) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas) return;
|
||||
try {
|
||||
if (window.FX.attach) window.FX.attach(canvas);
|
||||
if (window.FX.syncTo) window.FX.syncTo(canvas);
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(true);
|
||||
if (window.FX.start) window.FX.start();
|
||||
this._fxAmbientOn = true;
|
||||
} catch (e) { /* additive: never break the waiting screen */ }
|
||||
},
|
||||
|
||||
_stopAmbientFX: function() {
|
||||
this._fxAmbientOn = false;
|
||||
if (!window.FX) return;
|
||||
try {
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
if (window.FX.stop) window.FX.stop();
|
||||
if (window.FX.clear) window.FX.clear();
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
// Emit gentle amber embers rising from the scripted torch positions.
|
||||
// Throttled so we don't flood the FX particle system.
|
||||
_emitTorchEmbers: function(torchPositions, screenWidth, screenHeight) {
|
||||
if (!window.FX || !window.FX.sparkle) return;
|
||||
if (!torchPositions || !torchPositions.length) return;
|
||||
var canvas = this._fxBaseCanvas();
|
||||
if (!canvas || !screenWidth || !screenHeight) return;
|
||||
this._fxTick = (this._fxTick + 1) % 1000000;
|
||||
// Only emit on every 3rd frame to keep the embers subtle.
|
||||
if (this._fxTick % 3 !== 0) return;
|
||||
var cellW = canvas.width / screenWidth;
|
||||
var cellH = canvas.height / screenHeight;
|
||||
// One torch per emit, cycling through the scripted positions.
|
||||
var t = torchPositions[this._fxTick % torchPositions.length];
|
||||
if (!t) return;
|
||||
if (t.x < 0 || t.x >= screenWidth || t.y < 0 || t.y >= screenHeight) return;
|
||||
var px = (t.x + 0.5) * cellW;
|
||||
var py = (t.y + 0.5) * cellH;
|
||||
try {
|
||||
window.FX.sparkle(px, py, 'rgba(255, 170, 60, 0.9)');
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
|
||||
drawAnimatedWaitingScreen: function(screenWidth, screenHeight, drawBorderFn, drawCenteredTextFn) {
|
||||
if (!DisplayManager.ensureDisplay()) return;
|
||||
const display = DisplayManager.getDisplay();
|
||||
|
||||
|
||||
// Draw border
|
||||
drawBorderFn();
|
||||
|
||||
|
||||
// GFX3: layer the torchlight + ember FX overlay on top of the ROT scene
|
||||
this._startAmbientFX();
|
||||
|
||||
// Check if we're in "Awaiting payment" mode - use treasure hunt animation
|
||||
const isAwaitingPayment = typeof Game !== 'undefined' && Game._awaitingPayment;
|
||||
|
||||
@@ -184,7 +248,10 @@ var WaitingScreenAnimator = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GFX3: drifting embers from the scripted torch positions
|
||||
this._emitTorchEmbers(torchPositions, screenWidth, screenHeight);
|
||||
|
||||
// Update character positions
|
||||
this.updateAnimation();
|
||||
const anim = this._waitingAnimation;
|
||||
@@ -395,7 +462,10 @@ var WaitingScreenAnimator = {
|
||||
display.draw(torch.x, torch.y, ["#", "torch"], [wallColor, flameColor], ["transparent", "transparent"]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// GFX3: drifting embers from the scripted torch positions
|
||||
this._emitTorchEmbers(torchPositions, screenWidth, screenHeight);
|
||||
|
||||
// Update animation state
|
||||
this.updatePaymentAnimation();
|
||||
|
||||
@@ -855,8 +925,10 @@ var WaitingScreenAnimator = {
|
||||
|
||||
if (!this._animationEnabled) {
|
||||
this.resetAnimation();
|
||||
// GFX3: tear down the FX overlay when animation is switched off
|
||||
this._stopAmbientFX();
|
||||
}
|
||||
|
||||
|
||||
return this._animationEnabled;
|
||||
},
|
||||
|
||||
@@ -887,6 +959,8 @@ var WaitingScreenAnimator = {
|
||||
this._animating = false;
|
||||
this.resetAnimation();
|
||||
this.resetPaymentAnimation();
|
||||
// GFX3: tear down the FX overlay when the waiting screen ends
|
||||
this._stopAmbientFX();
|
||||
},
|
||||
|
||||
isAnimationEnabled: function() {
|
||||
|
||||
@@ -347,10 +347,14 @@ const SocketHandlers = {
|
||||
|
||||
onChatBroadcast: function(data) {
|
||||
const msgElement = $('<li style="color: #aaa;">');
|
||||
if (data.socketId) {
|
||||
// Prefer non-sensitive attribution fields; the server no longer sends the raw full
|
||||
// socket.id (S1). Fall back to a short slice only for legacy payloads.
|
||||
const sender = data.username || data.playerId || data.publicId ||
|
||||
(data.socketId ? String(data.socketId).substring(0, 6) : null);
|
||||
if (sender) {
|
||||
// Escape both fields — chat content is attacker-controlled. Defense in depth:
|
||||
// the server also escapes, but the client must never trust that.
|
||||
msgElement.html('<strong>' + escapeHtml(String(data.socketId).substring(0, 6)) + ':</strong> ' + escapeHtml(data.message));
|
||||
msgElement.html('<strong>' + escapeHtml(String(sender)) + ':</strong> ' + escapeHtml(data.message));
|
||||
} else {
|
||||
msgElement.text(data.message);
|
||||
}
|
||||
@@ -372,7 +376,7 @@ const SocketHandlers = {
|
||||
if (msg.id) {
|
||||
msgElement.attr('data-msg-id', msg.id);
|
||||
}
|
||||
const username = msg.playerId || msg.username || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
|
||||
const username = msg.playerId || msg.username || msg.publicId || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
|
||||
const isSystem = msg.type === 'system' || msg.socketId === 'system';
|
||||
if (isSystem) {
|
||||
msgElement.addClass('status');
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -83,14 +83,22 @@ const TransactionHistory = {
|
||||
});
|
||||
},
|
||||
|
||||
// These reads are now session-ownership gated (contract C2), so send the session token
|
||||
// (users.anon_token, persisted as localStorage['wownerogue_token']) as 'X-Session-Token'.
|
||||
_sessionHeaders: function() {
|
||||
var token = null;
|
||||
try { token = localStorage.getItem('wownerogue_token'); } catch (e) {}
|
||||
return token ? { 'X-Session-Token': token } : {};
|
||||
},
|
||||
|
||||
_fetchPayments: function(socketId) {
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payments?limit=50`)
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payments?limit=50`, { headers: this._sessionHeaders() })
|
||||
.then(res => res.json())
|
||||
.catch(() => ({ payments: [], total: 0 }));
|
||||
},
|
||||
|
||||
|
||||
_fetchPayouts: function(socketId) {
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payouts?limit=50`)
|
||||
return fetch(`/api/user/${encodeURIComponent(socketId)}/payouts?limit=50`, { headers: this._sessionHeaders() })
|
||||
.then(res => res.json())
|
||||
.catch(() => ({ payouts: [], total: 0, totalReceived: 0 }));
|
||||
},
|
||||
|
||||
+410
-38
@@ -13,6 +13,52 @@
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
DESIGN TOKENS — "dungeon console" palette
|
||||
Single source of truth for the torch-lit dungeon aesthetic. Hard-coded hex
|
||||
throughout the sheet is refactored to reference these incrementally.
|
||||
========================================================================= */
|
||||
:root {
|
||||
/* Surfaces */
|
||||
--bg: #111; /* deepest chrome (banner / footer) */
|
||||
--bg-panel: #222; /* side panels (status / chat) */
|
||||
--bg-deep: #0a0a0f;/* modal interior */
|
||||
--bg-input: #444; /* form fields */
|
||||
|
||||
/* Ink */
|
||||
--ink: #e0e0e0;/* default body text */
|
||||
--ink-dim: #888; /* muted / secondary text */
|
||||
|
||||
/* Accents */
|
||||
--neon: #0f0; /* neon-green primary */
|
||||
--good: #4ade80;/* success green */
|
||||
--cyan: #0ff; /* cyan */
|
||||
--gold: #fbbf24;/* torch gold */
|
||||
--gold-bright: #ffd700;/* bright gold highlight */
|
||||
--purple: #8b5cf6;/* arcane purple */
|
||||
--purple-soft: #c4b5fd;/* purple text */
|
||||
--torch: #f59e0b;/* torch-amber (frame glow) */
|
||||
|
||||
/* Structure */
|
||||
--rule: #444; /* hairline borders / dividers */
|
||||
--danger: #ef4444;/* danger / monster */
|
||||
--danger-hot: #f00; /* hot red (close buttons) */
|
||||
|
||||
/* Reusable glows */
|
||||
--glow-neon: rgba(0, 255, 0, 0.28);
|
||||
--glow-cyan: rgba(0, 255, 255, 0.28);
|
||||
--glow-purple: rgba(139, 92, 246, 0.30);
|
||||
--glow-torch: rgba(245, 158, 11, 0.30);
|
||||
}
|
||||
|
||||
/* Global console shell: PixelOperator everywhere with a monospace fallback. */
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
}
|
||||
|
||||
/* Updated layout with 5-row structure */
|
||||
.container {
|
||||
display: grid;
|
||||
@@ -29,41 +75,224 @@
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
gap: 1px;
|
||||
background-color: #222;
|
||||
background-color: var(--bg-panel);
|
||||
}
|
||||
|
||||
/* Header area */
|
||||
.banner {
|
||||
grid-area: header;
|
||||
background-color: #111;
|
||||
background-color: var(--bg);
|
||||
padding: 5px;
|
||||
color: white;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #444;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
box-shadow: inset 0 -2px 12px rgba(245, 158, 11, 0.06);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Status area for block height display */
|
||||
/* Status area for block height display — the "control console" column */
|
||||
.status {
|
||||
grid-area: status;
|
||||
background-color: #222;
|
||||
position: relative;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(245, 158, 11, 0.04), transparent 42%),
|
||||
var(--bg-panel);
|
||||
padding: 10px;
|
||||
color: #0f0;
|
||||
font-family: monospace;
|
||||
color: var(--neon);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
border-right: 1px solid #444;
|
||||
border-right: 1px solid var(--rule);
|
||||
box-shadow: inset 0 0 32px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
/* Torch-lit accent bar running down the console's leading edge. */
|
||||
.status::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, var(--torch), transparent 65%);
|
||||
box-shadow: 0 0 10px var(--torch);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
animation: torch-flicker 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#blockHeight, #connectionStatus {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
text-shadow: 0 0 6px rgba(0, 255, 0, 0.25);
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
CONTROL BAR — primary action buttons (#startButton, #watchGamesButton,
|
||||
#tavernButton, #characterButton, #shopButton, #animationToggleButton).
|
||||
Presentation lives here; ids + click handlers stay untouched in the HTML.
|
||||
Each variant only sets three custom props (accent / panel / label).
|
||||
========================================================================= */
|
||||
.ctrl-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
box-sizing: border-box;
|
||||
margin-top: 6px;
|
||||
padding: 8px 16px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
font-weight: bold;
|
||||
font-size: 15px;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
color: var(--btn-label, var(--ink));
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(0, 0, 0, 0.25)),
|
||||
var(--btn-panel, #1a1a1a);
|
||||
border: 2px solid var(--btn-accent, var(--rule));
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 0 rgba(0, 0, 0, 0);
|
||||
transition: box-shadow 0.18s ease, transform 0.08s ease,
|
||||
border-color 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.ctrl-btn:hover {
|
||||
filter: brightness(1.15);
|
||||
box-shadow: 0 0 14px var(--btn-glow, var(--glow-torch)),
|
||||
inset 0 0 10px rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.ctrl-btn:active {
|
||||
transform: translateY(1px);
|
||||
box-shadow: inset 0 0 12px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.ctrl-btn:focus-visible {
|
||||
outline: 2px solid var(--btn-label, var(--ink));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Variants — colours preserved from the original inline styles. */
|
||||
.ctrl-btn--start { --btn-panel: #0a5c0a; --btn-accent: var(--neon); --btn-label: #ff0; --btn-glow: var(--glow-neon); }
|
||||
.ctrl-btn--watch { --btn-panel: #053655; --btn-accent: #0af; --btn-label: #0af; --btn-glow: var(--glow-cyan); }
|
||||
.ctrl-btn--tavern { --btn-panel: #2a0a3a; --btn-accent: #a855f7; --btn-label: #d8b4fe; --btn-glow: var(--glow-purple); }
|
||||
.ctrl-btn--character{ --btn-panel: #163044; --btn-accent: #5aa7d8; --btn-label: #a7d8ff; --btn-glow: rgba(90, 167, 216, 0.32); }
|
||||
.ctrl-btn--shop { --btn-panel: #004400; --btn-accent: var(--neon); --btn-label: var(--neon); --btn-glow: var(--glow-neon); }
|
||||
.ctrl-btn--anim { --btn-panel: #5c5c0a; --btn-accent: #aa0; --btn-label: #ff0; --btn-glow: var(--glow-torch); padding: 6px 12px; font-size: 13px; }
|
||||
|
||||
/* The primary CTA gently pulses like a torch while idle (paused on hover). */
|
||||
.ctrl-btn--start { animation: torch-glow 3.4s ease-in-out infinite; }
|
||||
.ctrl-btn--start:hover { animation: none; }
|
||||
|
||||
/* =========================================================================
|
||||
HUD TOOLBAR — secondary "chip" controls (audio toggle + help / history /
|
||||
scores / manage-address). Small, bordered, glow-on-hover.
|
||||
========================================================================= */
|
||||
.hud-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--rule);
|
||||
font-size: 11px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.hud-toolbar label {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hud-chip {
|
||||
font-family: 'PixelOperator', monospace;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
color: var(--chip-label, var(--ink));
|
||||
background: var(--chip-panel, #1a1a1a);
|
||||
border: 1px solid var(--chip-accent, var(--rule));
|
||||
transition: box-shadow 0.18s ease, filter 0.18s ease, transform 0.08s ease;
|
||||
}
|
||||
|
||||
.hud-chip:hover {
|
||||
filter: brightness(1.2);
|
||||
box-shadow: 0 0 9px var(--chip-glow, var(--glow-torch));
|
||||
}
|
||||
|
||||
.hud-chip:active { transform: translateY(1px); }
|
||||
|
||||
.hud-chip:focus-visible {
|
||||
outline: 2px solid var(--chip-label, var(--ink));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hud-chip--push { margin-left: auto; }
|
||||
|
||||
.hud-chip--help { --chip-panel: #2d2d00; --chip-accent: var(--gold-bright); --chip-label: var(--gold-bright); --chip-glow: var(--glow-torch); }
|
||||
.hud-chip--history { --chip-panel: #1a1a4d; --chip-accent: var(--purple); --chip-label: var(--purple-soft); --chip-glow: var(--glow-purple); }
|
||||
.hud-chip--scores { --chip-panel: #4a2800; --chip-accent: var(--torch); --chip-label: var(--gold); --chip-glow: var(--glow-torch); }
|
||||
.hud-chip--address { --chip-panel: #053655; --chip-accent: var(--cyan); --chip-label: var(--cyan); --chip-glow: var(--glow-cyan); }
|
||||
|
||||
/* Info readouts inside the console */
|
||||
#creditsDisplay {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
color: var(--neon);
|
||||
background: #001a00;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #0a0;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#publicStats {
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #9cf;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
#liveCta {
|
||||
margin-top: 6px;
|
||||
cursor: pointer;
|
||||
background: rgba(80, 0, 0, 0.55);
|
||||
border: 1px solid #f33;
|
||||
border-radius: 4px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
color: #ff8a8a;
|
||||
font-weight: bold;
|
||||
transition: box-shadow 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
#liveCta:hover {
|
||||
filter: brightness(1.15);
|
||||
box-shadow: 0 0 12px rgba(255, 51, 51, 0.4);
|
||||
}
|
||||
|
||||
#sessionWarning {
|
||||
color: var(--torch);
|
||||
font-size: 10px;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
/* Make sure rotdis is properly visible across all browsers */
|
||||
.rotdis {
|
||||
grid-area: game;
|
||||
background-color: #000;
|
||||
border: 2px solid #444;
|
||||
border: 2px solid var(--rule);
|
||||
display: flex !important; /* Using flex to center canvas */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -89,10 +318,10 @@
|
||||
/* Chat messages area */
|
||||
.chat-container {
|
||||
grid-area: chat;
|
||||
background-color: #222;
|
||||
background-color: var(--bg-panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid #444;
|
||||
border-right: 1px solid var(--rule);
|
||||
overflow: hidden; /* Prevents chat from causing scrolling */
|
||||
}
|
||||
|
||||
@@ -122,8 +351,8 @@
|
||||
grid-area: input;
|
||||
background-color: #333;
|
||||
padding: 10px;
|
||||
border-top: 1px solid #444;
|
||||
border-bottom: 1px solid #444;
|
||||
border-top: 1px solid var(--rule);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
#chatForm {
|
||||
@@ -135,9 +364,10 @@
|
||||
flex-grow: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background-color: #444;
|
||||
background-color: var(--bg-input);
|
||||
color: #fff;
|
||||
border-radius: 3px 0 0 3px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
}
|
||||
|
||||
#chatForm button {
|
||||
@@ -147,6 +377,12 @@
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
border-radius: 0 3px 3px 0;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
transition: background-color 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
#chatForm button:hover {
|
||||
background-color: #0a5c0a;
|
||||
box-shadow: 0 0 10px var(--glow-neon);
|
||||
}
|
||||
|
||||
/* Add this to your stylesheet to use the font in other places */
|
||||
@@ -159,17 +395,18 @@
|
||||
|
||||
.footer {
|
||||
grid-area: footer;
|
||||
background-color: #111;
|
||||
color: #888;
|
||||
background-color: var(--bg);
|
||||
color: var(--ink-dim);
|
||||
padding: 5px;
|
||||
text-align: center;
|
||||
font-size: 0.8em;
|
||||
font-family: 'PixelOperator', sans-serif;
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* Game messages styling */
|
||||
li.game-start {
|
||||
color: #0f0;
|
||||
color: var(--neon);
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin: 5px 0;
|
||||
@@ -178,20 +415,20 @@ li.game-start {
|
||||
}
|
||||
|
||||
li.error {
|
||||
color: #f00;
|
||||
color: var(--danger-hot);
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
padding: 2px 5px;
|
||||
background: rgba(255,0,0,0.1);
|
||||
border-left: 2px solid #f00;
|
||||
border-left: 2px solid var(--danger-hot);
|
||||
}
|
||||
|
||||
li.debug {
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #0ff;
|
||||
border-left: 2px solid var(--cyan);
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
@@ -199,7 +436,16 @@ li.debug {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Modal styling */
|
||||
/* =========================================================================
|
||||
SHARED PANEL FRAME
|
||||
The overlay-centred modals (#addressModal / #helpModal via .modal-content)
|
||||
and the fixed-position panels (#payment-ui / #history-panel via
|
||||
.dungeon-modal, plus the JS-built #leaderboard-panel) all share one framed,
|
||||
torch-lit, inner-glowing look. Each panel keeps its signature accent colour
|
||||
through the --accent / --accent-glow / --panel-bg custom properties.
|
||||
========================================================================= */
|
||||
|
||||
/* -- Overlay-centred modals (address / help) -- */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -212,15 +458,19 @@ li.debug {
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #0a0a0f;
|
||||
border: 2px solid #0ff;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 45%),
|
||||
var(--bg-deep);
|
||||
border: 2px solid var(--cyan);
|
||||
border-radius: 8px;
|
||||
width: min(480px, 90vw);
|
||||
padding: 20px;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
font-family: 'PixelOperator', monospace;
|
||||
position: relative;
|
||||
box-shadow: 0 0 18px rgba(0, 255, 255, 0.2);
|
||||
box-shadow: 0 0 18px var(--glow-cyan),
|
||||
inset 0 0 42px rgba(0, 0, 0, 0.55);
|
||||
animation: mc-pop 0.2s ease-out;
|
||||
}
|
||||
|
||||
.modal-content h2 {
|
||||
@@ -247,9 +497,9 @@ li.debug {
|
||||
#addressModalInput {
|
||||
width: 100%;
|
||||
background: #020205;
|
||||
border: 1px solid #0ff;
|
||||
border: 1px solid var(--cyan);
|
||||
border-radius: 4px;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
padding: 10px;
|
||||
font-family: monospace;
|
||||
resize: vertical;
|
||||
@@ -267,11 +517,12 @@ li.debug {
|
||||
.modal-actions button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #0ff;
|
||||
border: 1px solid var(--cyan);
|
||||
background: #031c28;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: box-shadow 0.18s ease, filter 0.18s ease;
|
||||
}
|
||||
|
||||
.modal-actions button#addressModalSave {
|
||||
@@ -281,7 +532,8 @@ li.debug {
|
||||
}
|
||||
|
||||
.modal-actions button:hover {
|
||||
box-shadow: 0 0 10px rgba(0, 255, 255, 0.2);
|
||||
filter: brightness(1.12);
|
||||
box-shadow: 0 0 10px var(--glow-cyan);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
@@ -290,10 +542,12 @@ li.debug {
|
||||
right: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #0ff;
|
||||
color: var(--cyan);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.modal-close:hover { color: #fff; transform: scale(1.15); }
|
||||
|
||||
.modal-feedback {
|
||||
margin-top: 12px;
|
||||
@@ -313,6 +567,77 @@ li.debug {
|
||||
color: #9cf6ff;
|
||||
}
|
||||
|
||||
/* -- Fixed-position framed panels (payment / history) -- */
|
||||
.dungeon-modal {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 2000;
|
||||
min-width: 400px;
|
||||
max-width: 520px;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
border: 2px solid var(--accent, var(--torch));
|
||||
border-radius: 8px;
|
||||
color: var(--ink);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(0, 0, 0, 0.35), transparent 45%),
|
||||
var(--panel-bg, rgba(12, 12, 20, 0.97));
|
||||
box-shadow: 0 0 24px var(--accent-glow, var(--glow-torch)),
|
||||
inset 0 0 44px rgba(0, 0, 0, 0.55);
|
||||
animation: dm-fade 0.22s ease-out;
|
||||
}
|
||||
|
||||
#payment-ui.dungeon-modal {
|
||||
--accent: var(--neon);
|
||||
--accent-glow: var(--glow-neon);
|
||||
--panel-bg: rgba(0, 20, 0, 0.96);
|
||||
color: var(--neon);
|
||||
}
|
||||
|
||||
#history-panel.dungeon-modal {
|
||||
--accent: var(--purple);
|
||||
--accent-glow: var(--glow-purple);
|
||||
--panel-bg: rgba(10, 10, 30, 0.98);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* Shared header bar for framed panels */
|
||||
.dm-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.dm-head strong { font-size: 1.15em; }
|
||||
|
||||
/* Shared close control for framed panels */
|
||||
.dm-close {
|
||||
background: #500;
|
||||
color: var(--danger-hot);
|
||||
border: 1px solid var(--danger-hot);
|
||||
padding: 3px 8px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
transition: box-shadow 0.15s ease, filter 0.15s ease;
|
||||
}
|
||||
.dm-close:hover {
|
||||
filter: brightness(1.25);
|
||||
box-shadow: 0 0 10px rgba(255, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* The JS-built leaderboard panel keeps its inline amber frame; give it the
|
||||
same soft entrance + inner glow so it reads as part of the same system.
|
||||
(Opacity-only animation avoids clobbering its translate() centring.) */
|
||||
#leaderboard-panel {
|
||||
animation: dm-fade 0.22s ease-out;
|
||||
box-shadow: 0 0 22px var(--glow-torch), inset 0 0 40px rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Win feed toasts (global "someone escaped" announcements)
|
||||
@@ -330,8 +655,8 @@ li.debug {
|
||||
}
|
||||
.win-toast {
|
||||
background: rgba(0, 30, 0, 0.92);
|
||||
border: 1px solid #0f0;
|
||||
border-left: 4px solid #4ade80;
|
||||
border: 1px solid var(--neon);
|
||||
border-left: 4px solid var(--good);
|
||||
border-radius: 6px;
|
||||
color: #cffccf;
|
||||
font-family: 'PixelOperator', monospace;
|
||||
@@ -343,7 +668,52 @@ li.debug {
|
||||
transition: opacity 0.35s ease, transform 0.35s ease;
|
||||
}
|
||||
.win-toast.show { opacity: 1; transform: translateX(0); }
|
||||
.win-toast strong { color: #4ade80; }
|
||||
.win-toast strong { color: var(--good); }
|
||||
|
||||
/* =========================================================================
|
||||
Motion — tasteful transitions & keyframes (all respect reduced-motion)
|
||||
========================================================================= */
|
||||
@keyframes torch-flicker {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
18% { opacity: 0.55; }
|
||||
36% { opacity: 0.85; }
|
||||
58% { opacity: 0.5; }
|
||||
77% { opacity: 0.78; }
|
||||
}
|
||||
|
||||
@keyframes torch-glow {
|
||||
0%, 100% { box-shadow: 0 0 6px var(--btn-glow, var(--glow-torch)); }
|
||||
50% { box-shadow: 0 0 16px var(--btn-glow, var(--glow-torch)); }
|
||||
}
|
||||
|
||||
@keyframes dm-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes mc-pop {
|
||||
from { opacity: 0; transform: translateY(10px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.status::before,
|
||||
.ctrl-btn,
|
||||
.ctrl-btn--start,
|
||||
.hud-chip,
|
||||
.modal-content,
|
||||
.dungeon-modal,
|
||||
#leaderboard-panel,
|
||||
.win-toast,
|
||||
#liveCta,
|
||||
.modal-actions button,
|
||||
.modal-close,
|
||||
.dm-close,
|
||||
#chatForm button {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Mobile / touch support
|
||||
@@ -394,7 +764,7 @@ li.debug {
|
||||
#staticbanner { font-size: 15px !important; }
|
||||
.status {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #444;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 14px;
|
||||
@@ -418,6 +788,8 @@ li.debug {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Toolbar spans the full row when the console wraps. */
|
||||
.hud-toolbar { flex-basis: 100%; margin-top: 6px; }
|
||||
|
||||
/* Fixed-width modals would overflow tiny screens — make them fit */
|
||||
#payment-ui, #history-panel, #leaderboard-panel {
|
||||
@@ -449,7 +821,7 @@ body.touch #touch-dpad { display: grid; }
|
||||
width: 44px; height: 44px;
|
||||
font-size: 17px;
|
||||
background: rgba(0,40,0,0.5);
|
||||
color: #0f0;
|
||||
color: var(--neon);
|
||||
border: 2px solid rgba(0,255,0,0.7);
|
||||
border-radius: 9px;
|
||||
padding: 0;
|
||||
|
||||
Reference in New Issue
Block a user