chore: prepare the repository for public release

Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.

Private material moved out of the public tree (docs/private/, git-ignored):
  - the wallet keyfile recovery runbook and its helper scripts, which together
    published the mainnet wallet file path, the service account, backup snapshot
    filenames, and the wallet RPC listener state. The helper duplicated the same
    constants, so relocating the document alone would not have removed them.
  - the stagenet parallel-candidate runbook and its fixtures.
  - the dated production-state record, the internal remediation plan, and the
    TODO list. None are reference documentation for an outside reader.

Redacted or genericised:
  - operator home-directory paths in shipped scripts and units. The asset build
    script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
  - private LAN addresses in tests, env examples and deploy docs, replaced with
    RFC 5737 documentation addresses.
  - the default alert sender and the notification hub example.
  - the Monero wallet unit now matches the Wownero one: a dedicated service
    account and a config file, instead of the operator's login account with the
    wallet password in argv where any local user can read it from /proc.

Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.

Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.

Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.

Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.

127 suites / 1015 tests pass.
This commit is contained in:
jwinterm
2026-07-27 06:40:38 -04:00
parent 1cbbae73b2
commit ed24d43b56
207 changed files with 5023 additions and 8332 deletions
+2 -2
View File
@@ -148,7 +148,7 @@
<button id="characterButton" class="ctrl-btn ctrl-btn--character" title="Create / customize your character">
🎭 <span id="characterButtonLabel" class="lbl-full">CHARACTER</span><span class="lbl-short">CHAR</span>
</button>
<!-- Render mode + tile-pack picker (Style) — populated by spRenderPicker.js from entitlements. -->
<!-- Render mode + tile-pack picker (Style), populated by spRenderPicker.js from entitlements. -->
<div id="renderPicker" class="hidden"></div>
<!-- EXPLORE -->
@@ -313,7 +313,7 @@
</div>
</div>
<div style="text-align: center; color: #666; margin: 10px 0; font-size: 12px;">— or enter manually —</div>
<div style="text-align: center; color: #666; margin: 10px 0; font-size: 12px;">or enter manually</div>
<form id="addressModalForm">
<label for="addressModalInput" class="modal-label">Payout Address</label>
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Shared HTML-escaping helper — the single source of truth for escaping untrusted
* Shared HTML-escaping helper: the single source of truth for escaping untrusted
* (server- or user-supplied) text before it is inserted into innerHTML / jQuery .html().
* Escapes all five HTML-significant characters, including both quote types so the
* result is safe in both element text and quoted attribute values.
+57 -81
View File
@@ -1,15 +1,14 @@
// Refactored Game Object - Main game controller using modular architecture
// This provides a mock socket when the real one isn't available
// Main game controller. Modules (DisplayManager, ScreenManager, RenderEngine, GameState) hold the
// implementation; this object wires them together and owns the game lifecycle.
// Stub socket so emit() is always callable when the real socket has not been created yet.
if (typeof window !== 'undefined' && !window.socket) {
window.socket = {
emit: function(event, data) {
// Mock socket emit - removed debug logging
}
emit: function(event, data) {}
};
}
var Game = {
_screenWidth: 25, // Default values, will be updated from options
_screenWidth: 25, // Overridden from options when available
_screenHeight: 19,
_inputEnabled: true,
_messageLog: [],
@@ -21,13 +20,11 @@ var Game = {
_gameActive: false, // True when game is running (playing or spectating)
init: function() {
// Update screen dimensions from options
if (typeof options !== 'undefined') {
this._screenWidth = options.width || 25;
this._screenHeight = options.height || 19;
}
// Initialize all modules
if (!DisplayManager.init(this._screenWidth, this._screenHeight)) {
return false;
}
@@ -35,8 +32,7 @@ var Game = {
ScreenManager.init(this._screenWidth, this._screenHeight);
RenderEngine.init(this._screenWidth, this._screenHeight);
GameState.init();
// Draw welcome screen
ScreenManager.drawWelcomeScreen();
return true;
@@ -58,12 +54,12 @@ var Game = {
DisplayManager.clearDisplay();
// Force clear all display caches and artifacts
// Drop cached display glyphs so no welcome-screen artifacts survive into the level.
if (typeof DisplayManager !== 'undefined' && DisplayManager.forceClearToBlack) {
DisplayManager.forceClearToBlack();
}
// Stop the block simulation to prevent welcome screen from interfering
// The welcome screen's block simulation draws on the same display and must be stopped first.
if (typeof ScreenManager !== 'undefined' && ScreenManager.stopBlockSimulation) {
ScreenManager.stopBlockSimulation();
}
@@ -71,9 +67,9 @@ var Game = {
try {
GameState.setGameActive(true);
// Clear any previous game state but preserve what we need for initialization
// Clear previous game state. _visibleTiles is left alone: initializeMap seeds it from
// the server's initial visible set.
GameState._exploredTiles = {};
// Don't clear _visibleTiles yet - let initializeMap handle it properly
GameState._map = {};
GameState._items = {};
GameState._monster = null;
@@ -81,17 +77,14 @@ var Game = {
GameState._exit = null;
GameState._treasure = null;
// Initialize player
GameState.initializePlayer(playerData);
// Initialize other game objects
GameState._monster = monsterData;
GameState._items = itemData;
GameState._entrance = null;
GameState._exit = null;
GameState._treasure = null;
// Initialize lighting and torch data
if (lightingData) {
GameState._lighting = lightingData;
}
@@ -99,7 +92,6 @@ var Game = {
GameState._torches = torchData;
}
// Initialize map and validate player position
GameState.initializeMap(mapData, initialVisibleTiles, this._screenWidth, this._screenHeight);
if (!GameState.validatePlayerPosition(this._screenWidth, this._screenHeight)) {
@@ -107,10 +99,8 @@ var Game = {
return false;
}
// Compute field of view
GameState.computeFieldOfView();
// Setup scheduler and engine
if (!GameState._scheduler) GameState._scheduler = new ROT.Scheduler.Simple();
else GameState._scheduler.clear();
if (!GameState._engine) GameState._engine = new ROT.Engine(GameState._scheduler);
@@ -125,8 +115,7 @@ 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.
// Starts the FX overlay (ambient embers, flicker, particle loop). No-op without window.FX.
this._fxStart();
return true;
@@ -147,44 +136,41 @@ var Game = {
}
},
// Update game state with new data from server
// Apply a state update pushed by the server.
updateGameState: function(data) {
// JUICE: snapshot treasure/item count so we can detect a server-driven pickup below.
// Snapshot of the treasure/item count; a drop after the update means a pickup happened.
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).
// An item vanished: gold burst for the pickup.
const pp = this._playerScreenPixel();
window.FX.burst(pp.x, pp.y, '#ffd700', 28);
} else {
// Otherwise run proximity effects for server-authoritative moves.
// Proximity effects for server-authoritative moves.
this._fxOnPlayerMoved();
}
} catch (e) { /* FX is best-effort; never break gameplay */ }
}
},
// Legacy alias (some handlers referenced updateGame previously)
// Alias kept for handlers that call updateGame.
updateGame: function(data) {
return this.updateGameState(data);
},
// Handle game over event from server
// Handle the game-over event from the server.
endGame: function(data) {
try {
GameState.setGameActive(false);
// Decide which screen to draw
if (data && data.reason === 'monster') {
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('monster');
} else if (data && data.reason === 'escaped') {
@@ -195,34 +181,31 @@ 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).
// Outcome FX. The loop stays running so the burst/shake 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.
// Monster, timeout or other loss.
window.FX.shake(12, 500);
window.FX.flash('rgba(255,0,0,1)', 0.4, 450);
}
// Gameplay is over: quiet the ambient torch embers.
// Gameplay is over, so 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);
// Display score / treasure info overlay
if (ScreenManager && ScreenManager.drawCenteredText && DisplayManager.ensureDisplay()) {
const baseY = Math.floor(ScreenManager._screenHeight / 2) + 5;
ScreenManager.drawCenteredText(baseY, `Score: ${score}`);
@@ -231,37 +214,37 @@ var Game = {
}
}
// Debounce + delayed activation of restart
// Restart accepts Enter only after activateDelay, so a keypress in flight at game over
// cannot skip the outcome screen.
this._awaitingRestart = false;
const activateDelay = 800; // ms before we accept Enter
const autoReturnDelay = 10000; // auto return after 10s
const activateDelay = 800;
const autoReturnDelay = 10000;
// Show hint after activation delay
setTimeout(() => {
this._awaitingRestart = true;
if (ScreenManager && ScreenManager.drawCenteredText && DisplayManager.ensureDisplay()) {
const y = ScreenManager._screenHeight - 2;
const fullHint = 'Press Enter to return to title';
const shortHint = 'Press Enter';
const maxLen = ScreenManager._screenWidth - 2; // leave small margin
const maxLen = ScreenManager._screenWidth - 2; // Leaves a one-cell margin each side
const hint = fullHint.length > maxLen ? shortHint : fullHint;
ScreenManager.drawCenteredText(y, hint);
}
}, activateDelay);
// Auto return timer
clearTimeout(this._autoReturnTimer);
this._autoReturnTimer = setTimeout(() => {
if (this._awaitingRestart) {
this._awaitingRestart = false;
this._fxStop(); // JUICE: tear down FX loop so it never leaks
this._fxStop(); // Tears down the FX loop so requestAnimationFrame does not leak
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
ScreenManager.drawWelcomeScreen();
}
}
}, autoReturnDelay);
// One-time listener setup
// The keydown listener is attached once and gated on _awaitingRestart, so repeated
// game-overs do not stack handlers.
if (!this._restartListenerAttached) {
this._restartListenerAttached = true;
document.addEventListener('keydown', (e) => {
@@ -269,7 +252,7 @@ var Game = {
if (e.key === 'Enter') {
this._awaitingRestart = false;
clearTimeout(this._autoReturnTimer);
this._fxStop(); // JUICE: tear down FX loop so it never leaks
this._fxStop(); // Tears down the FX loop so requestAnimationFrame does not leak
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
ScreenManager.drawWelcomeScreen();
}
@@ -289,9 +272,9 @@ var Game = {
_drawGameScreen: function() {
const gameState = GameState.getGameStateForRender();
// Render the dungeon through the render kit (Tiled / ASCII / Iso / 3D + unlocked packs) with
// a player-centered camera + goblin monster sprite. The legacy RenderEngine remains a HARD
// fallback (used only if the kit is unavailable or a render throws), so the game never blanks.
// The dungeon renders through the render kit (Tiled / ASCII / Iso / 3D plus unlocked packs)
// with a player-centered camera. RenderEngine is the fallback path, used only when the kit
// is unavailable or a render fails, so a live game never blanks.
var rk = false;
if (window.RK && RK.SPGame && RK.SPGame.available()) {
RK.SPGame.show();
@@ -312,7 +295,7 @@ var Game = {
}
},
// Screen drawing methods - delegate to ScreenManager
// Screen drawing delegates to ScreenManager.
_drawWelcomeScreen: function() {
ScreenManager.drawWelcomeScreen();
},
@@ -327,16 +310,14 @@ var Game = {
drawWaitingScreen: function() {
ScreenManager.drawWaitingScreen();
// Overlay unconfirmed payment badge if present
if (this._unconfirmedPayment && typeof ScreenManager !== 'undefined' && DisplayManager.ensureDisplay()) {
const display = DisplayManager.getDisplay();
const badgeText = 'Unconfirmed (mempool)';
const xStart = Math.max(1, Math.floor((this._screenWidth - badgeText.length) / 2));
const y = 1; // top line inside border
const y = 1; // First row inside the border
for (let i = 0; i < badgeText.length; i++) {
display.draw(xStart + i, y, badgeText[i], 'rgba(180,230,255,0.9)', 'transparent');
}
// Small pulsing dot
const pulse = 0.4 + Math.sin(Date.now()/300)*0.4;
display.draw(xStart - 2, y, '*', `rgba(120,200,255,${pulse})`, 'transparent');
}
@@ -348,7 +329,7 @@ var Game = {
ScreenManager.stopWaitingScreen();
},
// Display mode switching - delegate to DisplayManager
// Display mode switching delegates to DisplayManager.
switchToAsciiMode: function() {
DisplayManager.switchToAsciiMode();
},
@@ -357,12 +338,11 @@ var Game = {
DisplayManager.switchToTileMode();
},
// Player movement - delegate to GameState
// Player movement delegates to GameState.
movePlayer: function(dx, dy) {
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 */ }
}
@@ -370,21 +350,20 @@ var Game = {
},
// ------------------------------------------------------------------
// 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).
// FX helpers. These only consume the window.FX overlay API and never define it. Every call is
// guarded, so the game stays fully functional where the FX module is absent (match.html,
// tavern.html). Coordinates use the same camera-centered mapping as the renderer: the player is
// always drawn at the center of the view.
// ------------------------------------------------------------------
// The ROT.js display container is the base canvas FX layers above.
// The ROT.js display container is the base canvas that FX layers sit 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.
// Convert a world cell to its 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);
@@ -401,7 +380,7 @@ var Game = {
return this._cellToScreenPixel(p.x, p.y);
},
// Chebyshev (king-move) distance — matches the grid feel.
// Chebyshev (king-move) distance, matching movement on the grid.
_fxDist: function(ax, ay, bx, by) {
return Math.max(Math.abs(ax - bx), Math.abs(ay - by));
},
@@ -436,7 +415,7 @@ var Game = {
var pp = this._playerScreenPixel();
// Treasure proximity: on-cell => gold burst, nearby => sparkle.
// Treasure proximity: on the cell gives a gold burst, within two cells gives a sparkle.
var cells = this._fxTreasureCells();
var onTreasure = false, nearTreasure = false;
for (var i = 0; i < cells.length; i++) {
@@ -450,10 +429,9 @@ var Game = {
window.FX.sparkle(pp.x, pp.y, '#fbbf24');
}
// NOTE: removed the monster-proximity red screen flash — it fired on nearly every step
// (monster usually within 3 tiles) and read as a constant annoying pulse. The monster is
// already visible on-screen, so proximity needs no full-screen flash. Reserve FX.flash for
// genuine one-shot events (win/lose in handleGameOver).
// Monster proximity deliberately triggers no screen flash: the monster is usually within a
// few tiles and already visible on screen, so a per-step flash would fire almost constantly.
// FX.flash is reserved for one-shot events such as win and lose.
},
// Attach + start the FX loop for a fresh game.
@@ -468,7 +446,7 @@ var Game = {
} catch (e) { /* best-effort */ }
},
// Stop + clear the FX loop so requestAnimationFrame never leaks.
// Stop and clear the FX loop so requestAnimationFrame does not leak.
_fxStop: function() {
if (!window.FX) return;
try {
@@ -490,7 +468,7 @@ var Game = {
}
},
// Debug functions - delegate to GameState
// Debug helpers delegate to GameState.
debugPrintMap: function() {
GameState.debugPrintMap(this._screenWidth, this._screenHeight);
},
@@ -499,7 +477,7 @@ var Game = {
GameState.debugTileMapping();
},
// Properties for backward compatibility
// Accessors forwarding to GameState, kept for callers that read Game.* directly.
get _gameActive() {
return GameState.isGameActive();
},
@@ -537,7 +515,7 @@ var Game = {
Game._pendingPaymentDetected = function(info) {
this._unconfirmedPayment = true;
this._unconfirmedPaymentInfo = info || {};
this._awaitingPayment = false; // no longer just awaiting creation; we've seen a tx
this._awaitingPayment = false; // A transaction exists, so this is no longer awaiting creation
};
Game._pendingPaymentConfirmed = function() {
@@ -550,7 +528,7 @@ Game._paymentRequested = function() {
this._awaitingPayment = true;
};
// Debug utility functions (keep as-is for compatibility)
// Debug utilities: log to console, to the on-page debug panel, and to the server.
const GameDebug = {
log: function(message) {
console.log(message);
@@ -563,12 +541,11 @@ const GameDebug = {
if (debugContent) {
const timestamp = new Date().toLocaleTimeString();
debugContent.innerHTML += `<div>[${timestamp}] ${message}</div>`;
// Keep only last 10 debug messages
// Cap the panel at the ten most recent messages.
const lines = debugContent.children;
if (lines.length > 10) {
debugContent.removeChild(lines[0]);
}
// Auto-scroll to bottom
const debugDisplay = document.getElementById('debug-display');
debugDisplay.scrollTop = debugDisplay.scrollHeight;
}
@@ -583,15 +560,14 @@ const GameDebug = {
},
body: JSON.stringify({ message: message })
}).catch(err => {
// Silently fail if server unavailable
// Debug reporting is best-effort; an unavailable server must not surface an error.
});
} catch (err) {
// Silently fail
// Debug reporting is best-effort.
}
}
};
// Make them available globally
if (typeof window !== 'undefined') {
window.Game = Game;
window.GameDebug = GameDebug;
+25 -53
View File
@@ -57,7 +57,6 @@ var GameState = {
console.warn("Invalid or missing playerData.x/y, using default player position.", playerData);
this._player = { x: 12, y: 12 }; // Default position
}
// console.log("Player initialized to:", this._player);
return this._player;
},
@@ -68,7 +67,7 @@ var GameState = {
let mapInitialized = false;
// Try to initialize from mapData.tiles
// Map source precedence: mapData.tiles, then array mapData, then initialVisibleTiles.
if (mapData && mapData.tiles && typeof mapData.tiles === 'object' && Object.keys(mapData.tiles).length > 0) {
for (var yKey in mapData.tiles) {
const y = parseInt(yKey);
@@ -79,8 +78,7 @@ var GameState = {
}
}
mapInitialized = true;
}
// Try array-based mapData
}
else if (mapData && Array.isArray(mapData) && mapData.length > 0) {
for (let y = 0; y < mapData.length; y++) {
this._map[y] = {};
@@ -91,8 +89,7 @@ var GameState = {
}
}
mapInitialized = true;
}
// Try initialVisibleTiles
}
else if (initialVisibleTiles && typeof initialVisibleTiles === 'object' && Object.keys(initialVisibleTiles).length > 0) {
this._visibleTiles = JSON.parse(JSON.stringify(initialVisibleTiles)); // Deep copy
@@ -107,7 +104,7 @@ var GameState = {
mapInitialized = true;
}
// Create default map if nothing worked
// Fallback: a bordered empty room so the client still renders without server map data.
if (!mapInitialized) {
console.warn("No valid mapData or initialVisibleTiles provided. Creating default map for testing.");
const defaultMapWidth = screenWidth;
@@ -132,7 +129,6 @@ var GameState = {
},
validatePlayerPosition: function(screenWidth, screenHeight) {
// Validate player position against the map
if (!this._map[this._player.y] || this._map[this._player.y][this._player.x] === undefined) {
console.error(`Player position (${this._player.x}, ${this._player.y}) is invalid or outside map boundaries. Attempting to find fallback.`);
let foundFallback = false;
@@ -140,7 +136,7 @@ var GameState = {
if (this._map[y_scan]) {
for (let x_scan = 0; x_scan < screenWidth; x_scan++) {
const tile = this._map[y_scan][x_scan];
// Check for both legacy (0) and new ("'1", "'2") floor tiles
// Floor is encoded either as 0 or as the tile ids "'1" / "'2".
if (tile === 0 || tile === "'1" || tile === "'2") {
this._player.x = x_scan;
this._player.y = y_scan;
@@ -160,18 +156,17 @@ var GameState = {
},
computeFieldOfView: function() {
// Initialize FOV with corrected wall detection callback
// The callback reports transparency: true for tiles that pass light, false for blockers.
var fov = new ROT.FOV.PreciseShadowcasting(function(x, y) {
// Return true if the tile is transparent (walkable), false if it blocks light
if (!this._map[y] || this._map[y][x] === undefined) {
return false; // Unknown tiles block light
}
const tile = this._map[y][x];
// Check for both legacy (0) and new ("'1", "'2") floor tiles
// Floor is encoded either as 0 or as the tile ids "'1" / "'2".
return tile === 0 || tile === "'1" || tile === "'2";
}.bind(this));
// If map was NOT built from initialVisibleTiles, compute FOV now
// A map built from initialVisibleTiles already carries visibility; only compute otherwise.
if (!this._visibleTiles || Object.keys(this._visibleTiles).length === 0) {
this._visibleTiles = {};
fov.compute(this._player.x, this._player.y, 10, function(x, y, r, visibility) {
@@ -181,13 +176,12 @@ var GameState = {
}
}.bind(this));
}
// NOTE: Don't call updateExploredTiles() here on initial game load
// Explored tiles should only be updated when player moves, not on first FOV computation
// Explored tiles accumulate on player movement, so the initial FOV pass does not call
// updateExploredTiles().
},
updateExploredTiles: function() {
// Add all currently visible tiles to explored tiles
for (var yKey in this._visibleTiles) {
const y = parseInt(yKey);
if (!this._exploredTiles[y]) this._exploredTiles[y] = {};
@@ -211,9 +205,8 @@ var GameState = {
try {
let needsRedraw = false;
// Multi-level descent: a new depth means a brand-new level. The accumulated map /
// explored / visible tiles belong to the level we just left, so clear them — otherwise
// the old level's walls ghost through the fresh one.
// A new depth is a new level: the accumulated map, explored and visible tiles belong to
// the previous level and are cleared, otherwise its walls ghost through the fresh one.
if (typeof data.depth === 'number' && data.depth !== this._depth) {
this._depth = data.depth;
if (typeof data.maxDepth === 'number') this._maxDepth = data.maxDepth;
@@ -223,25 +216,21 @@ var GameState = {
needsRedraw = true;
}
// Update player state
if (data.player) {
this._player = data.player;
needsRedraw = true;
}
// Update monster state
if (data.monster !== undefined) {
this._monster = data.monster;
needsRedraw = true;
}
// Update items
if (data.items !== undefined) {
this._items = data.items;
needsRedraw = true;
}
// Update entrance, exit, treasure
if (data.entrance !== undefined) {
this._entrance = data.entrance;
needsRedraw = true;
@@ -255,7 +244,6 @@ var GameState = {
needsRedraw = true;
}
// Update lighting and torch data
if (data.lighting !== undefined) {
this._lighting = data.lighting;
needsRedraw = true;
@@ -265,11 +253,10 @@ var GameState = {
needsRedraw = true;
}
// Update visible tiles (most critical for movement feedback)
// Visible tiles drive movement feedback, so they also refresh the explored set.
if (data.visibleTiles && typeof data.visibleTiles === 'object' && Object.keys(data.visibleTiles).length > 0) {
this._visibleTiles = data.visibleTiles;
// Debug log for specific problematic coordinates
if (this._visibleTiles[18] && (this._visibleTiles[18][36] !== undefined || this._visibleTiles[18][35] !== undefined)) {
const clientDebug = `🔍 CLIENT y=18: x=35: ${this._visibleTiles[18][35]}, x=36: ${this._visibleTiles[18][36]}`;
if (window.GameDebug) window.GameDebug.updateDebugDisplay(clientDebug);
@@ -296,19 +283,15 @@ var GameState = {
const newX = this._player.x + dx;
const newY = this._player.y + dy;
// Basic bounds checking
if (newX < 0 || newY < 0 || newX >= screenWidth || newY >= screenHeight) {
console.warn("Attempted to move player outside of bounds:", newX, newY);
return;
}
// Check if the new position is a wall (1) or out of map bounds
// Tile value 1 is wall; undefined is off-map. Both block movement.
if (this._map[newY] && this._map[newY][newX] !== undefined && this._map[newY][newX] !== 1) {
// Move the player
this._player.x = newX;
this._player.y = newY;
// Update visibility and redraw
this.updateExploredTiles();
return true;
} else {
@@ -316,9 +299,8 @@ var GameState = {
}
},
// Debug functions
// Debug helpers: console output here is intentional.
debugPrintMap: function(screenWidth, screenHeight) {
// Map debugging - preserve console.log for debug function
console.log("Current game map:");
for (let y = 0; y < screenHeight; y++) {
let row = "";
@@ -334,7 +316,6 @@ var GameState = {
},
debugTileMapping: function() {
// Tile mapping debugging - preserve console.log for debug function
const testValues = [0, 1, undefined, null];
for (const val of testValues) {
const char = (val === 1) ? '#' : "\'";
@@ -344,36 +325,33 @@ var GameState = {
}
},
// Compute field of view for the player (LOCAL TESTING ONLY - server handles FOV)
// Local FOV, used only when running without a server; in normal play the server owns FOV.
// This definition shadows the earlier computeFieldOfView on the same object literal.
computeFieldOfView: function() {
if (!this._player) {
console.warn("Cannot compute FOV: player not initialized");
return;
}
// Warning for local FOV computation (keep for debugging)
// console.log("⚠️ LOCAL FOV COMPUTATION (should only be used for testing without server)");
const oldVisibleTiles = JSON.parse(JSON.stringify(this._visibleTiles));
this._visibleTiles = {};
const fov = new ROT.FOV.PreciseShadowcasting(function(x, y) {
const tile = this._map[y] && this._map[y][x];
// Check for both legacy (0) and new ("'1", "'2") floor tiles
// Floor is encoded either as 0 or as the tile ids "'1" / "'2".
return tile === 0 || tile === "'1" || tile === "'2";
}.bind(this));
fov.compute(this._player.x, this._player.y, 6, function(x, y, r, visibility) {
if (!this._visibleTiles[y]) this._visibleTiles[y] = {};
this._visibleTiles[y][x] = this._map[y] && this._map[y][x] !== undefined ? this._map[y][x] : 0;
// Mark as explored
if (!this._exploredTiles[y]) this._exploredTiles[y] = {};
this._exploredTiles[y][x] = this._map[y] && this._map[y][x] !== undefined ? this._map[y][x] : 0;
}.bind(this));
},
// Get game state data for rendering
// Snapshot consumed by the render kit and the legacy render engine.
getGameStateForRender: function() {
const renderState = {
map: this._map,
@@ -394,7 +372,7 @@ var GameState = {
return renderState;
},
// Move player and update game state
// This definition shadows the earlier movePlayer on the same object literal.
movePlayer: function(dx, dy, screenWidth, screenHeight) {
if (!this._player) {
console.warn("Cannot move player: player not initialized");
@@ -404,23 +382,17 @@ var GameState = {
const newX = this._player.x + dx;
const newY = this._player.y + dy;
// Check bounds
if (newX < 0 || newX >= screenWidth || newY < 0 || newY >= screenHeight) {
// console.log("Player move blocked: out of bounds");
return false;
}
// Check if the new position is passable
// Tile value 1 is wall and blocks movement.
if (this._map[newY] && this._map[newY][newX] === 1) {
// console.log("Player move blocked: wall at", newX, newY);
return false;
}
// Update player position
this._player.x = newX;
this._player.y = newY;
// console.log("Player moved to:", this._player.x, this._player.y);
return true;
}
};
+2 -2
View File
@@ -95,7 +95,7 @@
// 🎭 + this title) so it doesn't read like a mode/role button.
label.textContent = (info && info.label ? info.label : 'CHARACTER').toUpperCase();
btn.title = 'Create / customize your character'
+ (info && info.label ? ' — currently ' + info.label : '')
+ (info && info.label ? ': currently ' + info.label : '')
+ (info && info.premium ? ' (premium)' : '');
},
@@ -207,7 +207,7 @@
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 */ }
} catch (_) { /* FX is purely additive: never let it break the avatar */ }
},
drawLegendIcon: function (tileX, tileY, opts) {
+6 -6
View File
@@ -1,15 +1,15 @@
// FXOverlay — dependency-free 2D-canvas FX layer for the FREE ROT.js dungeon (Pipeline A).
// 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
// #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
// 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';
@@ -116,7 +116,7 @@
},
// Returns the 2D context of a correctly sized/positioned overlay, or null
// (which makes every public method a no-op — keeps other pages unaffected).
// (which makes every public method a no-op: keeps other pages unaffected).
_ensure: function () {
if (!doc) return null;
var base = this._resolveBase();
@@ -396,7 +396,7 @@
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.
// entirely: no lingering rAF, no leak. A future call restarts it.
this._running = false;
this._raf = 0;
this._active = false;
+2 -2
View File
@@ -268,7 +268,7 @@ var RenderEngine = {
// because canDrawPlayer returned true, but the overlay draw may still fail.
if (!drew) {
try { display.draw(centerX, centerY, playerTile, "#FFF", "transparent"); }
catch (_) { /* ignore — next frame will retry */ }
catch (_) { /* ignore: next frame will retry */ }
}
}
@@ -281,7 +281,7 @@ var RenderEngine = {
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 */ }
} catch (_) { /* FX is purely cosmetic: never block the dungeon render */ }
}
// Render message at bottom if present
+3 -3
View File
@@ -158,7 +158,7 @@ var ScreenManager = {
? GameTiles.getTreasureTile()
: (this._cryptoType === 'XMR' ? '$M' : '$W');
// Every row uses a uniform icon slot at `centerX` and a single fixed text column, so the
// icons share a left edge and all four descriptions line up — regardless of whether the
// icons share a left edge and all four descriptions line up, regardless of whether the
// icon is a 1-cell glyph (>, ~, $) or the 2-cell player avatar sprite.
const legendItems = [
{ tile: "@2", text: "This is you", role: "player" },
@@ -175,8 +175,8 @@ var ScreenManager = {
const centerX = Math.floor(this._screenWidth / 2) - 10;
const textX = centerX + ICON_COLS + 1; // one fixed text column for every row
for (let item of legendItems) {
// Draw EVERY icon (incl. the player's original hero tile) as a grid tile — no avatar
// overlay — so all four share a left edge + baseline and the hero is the plain tile.
// Draw EVERY icon (incl. the player's original hero tile) as a grid tile, no avatar
// overlay, so all four share a left edge + baseline and the hero is the plain tile.
const glyph = window.options?.tileMap?.hasOwnProperty(item.tile) ? item.tile : "?";
const gx = centerX + Math.floor((ICON_COLS - (glyph.length || 1)) / 2);
display.draw(gx, y, glyph, "transparent", "transparent");
+5 -5
View File
@@ -144,7 +144,7 @@ var WaitingScreenAnimator = {
},
// JUICE: a one-shot particle burst at a grid cell (treasure grab / escape / death). Purely
// additive over window.FX — no-op if FX is absent — and tile-agnostic, so it works identically
// additive over window.FX, no-op if FX is absent, and tile-agnostic, so it works identically
// in every pack (it draws particles, never tiles). `spread` in grid cells, `count` particles.
_burst: function(gridX, gridY, color, count, screenWidth, screenHeight, spread) {
if (!window.FX || !window.FX.sparkle) return;
@@ -182,7 +182,7 @@ var WaitingScreenAnimator = {
}
// Fixed-position animated text. The label reflects the ACTUAL wait state, not this function's
// historical name — the two animations were wired to the opposite labels. This branch runs
// historical name: the two animations were wired to the opposite labels. This branch runs
// while genuinely awaiting a payment.
let y = Math.floor(screenHeight / 2) - 8;
let baseText = (typeof Game !== 'undefined' && Game._awaitingPayment) ? 'Awaiting payment' : 'Awaiting next block';
@@ -275,7 +275,7 @@ var WaitingScreenAnimator = {
const anim = this._waitingAnimation;
// JUICE: a SUBTLE, tight sparkle when the hero breaks for the exit with the loot. No red
// "caught" burst — keep it a small glint, not an explosion near the torches.
// "caught" burst: keep it a small glint, not an explosion near the torches.
if (this._epicPhase !== anim.phase) {
if (anim.phase === 'escape') this._burst(anim.playerX, anim.playerY, 'rgba(255,220,90,0.9)', 6, screenWidth, screenHeight, 0.7);
this._epicPhase = anim.phase;
@@ -414,7 +414,7 @@ var WaitingScreenAnimator = {
// Fixed-position animated text. This treasure-hunt animation runs for the block-QUEUE wait
// (see drawAnimatedWaitingScreen routing), so show the block label unless we're genuinely
// awaiting a payment. (Function name is historical — do not trust it for the label.)
// awaiting a payment. (Function name is historical: do not trust it for the label.)
let y = Math.floor(screenHeight / 2) - 8;
const dots = ".".repeat((Math.floor(time / 500) % 4));
const paddedDots = dots.padEnd(3, " ");
@@ -498,7 +498,7 @@ var WaitingScreenAnimator = {
this.updatePaymentAnimation();
// JUICE: a SUBTLE, tight one-shot sparkle on the treasure grab / clean escape. Kept small and
// low-spread so it reads as a glint on the hero, not an explosion — and never sprays onto the
// low-spread so it reads as a glint on the hero, not an explosion, and never sprays onto the
// wall torches. No red "death" burst (the animation's own red shockwave already covers that).
if (this._payPhase !== anim.phase) {
if (anim.phase === 'grab') this._burst(anim.treasureX, anim.treasureY, 'rgba(255,220,90,0.9)', 6, screenWidth, screenHeight, 0.6);
+40 -62
View File
@@ -4,11 +4,10 @@
// Is a blocking dialog on screen?
//
// The document-level Enter shortcut submits a RANKED (paid-intent) entry, and it used to fire
// regardless of what was on top — including while the entry-choice modal or the payment modal was
// open. Since those modals advertise "ENTER ▸" on their own cards, one Enter silently submitted an
// entry the player had not chosen, and the click that followed submitted a second one. Anything
// that takes over the screen must swallow the shortcut.
// The document-level Enter shortcut submits a ranked (paid-intent) entry. The entry-choice and
// payment modals advertise "ENTER" on their own cards, so anything that takes over the screen must
// swallow the shortcut; otherwise Enter submits an entry the player has not chosen and the
// following click submits a second one.
function modalIsOpen() {
if (document.getElementById('entryChoiceOverlay')) return true;
var overlays = document.querySelectorAll('.modal-overlay');
@@ -33,7 +32,7 @@ const InputHandler = {
_lastMoveTime: 0,
_moveCooldown: 100, // Minimum 100ms between moves
_pendingMove: null,
_initialized: false, // Flag to prevent multiple initializations
_initialized: false, // Guards against repeated init() calls binding duplicate handlers
init: function() {
if (this._initialized) {
@@ -51,12 +50,11 @@ const InputHandler = {
setupChatForm: function() {
const MAX_CHAT_LENGTH = 200;
// Character counter update
$('#chatInput').on('input', function() {
const len = $(this).val().length;
const counter = $('#charCounter');
counter.text(len + '/' + MAX_CHAT_LENGTH);
// Change color when approaching limit
// Red at the limit, amber within the last 20%.
if (len >= MAX_CHAT_LENGTH) {
counter.css('color', '#f66');
} else if (len >= MAX_CHAT_LENGTH * 0.8) {
@@ -70,11 +68,9 @@ const InputHandler = {
e.preventDefault();
var msg = $('#chatInput').val().trim();
// Don't send empty messages
if (!msg) return false;
// If the message is "enter", always send it to server
// The server will handle queueing logic and timing
// "enter" always goes to the server, which owns queueing logic and timing.
if (msg.toLowerCase() === 'enter') {
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('join_queue')) return false;
@@ -82,15 +78,11 @@ const InputHandler = {
socket.emit('chat message', msg);
}
// Don't automatically switch to waiting screen - let the server control screen state
// The server will send appropriate events (payment_created, waiting_status, etc.)
// to control what the user sees
// Add visual feedback that request was sent
// The server controls screen state via payment_created, waiting_status and similar
// events, so this path does not switch to the waiting screen itself.
$('#messages').append($('<li style="color: #0f0;">').text("🔑 Processing game entry request..."));
UI.scrollChat();
} else {
// Send other chat messages normally
socket.emit('chat message', msg);
}
@@ -99,24 +91,22 @@ const InputHandler = {
return false;
});
// Set initial focus to chat input
$('#chatInput').focus();
UI.updateFocusIndicator();
},
setupFocusHandlers: function() {
// Add focus/blur listeners for visual indicator
// Focus/blur drive the visual focus indicator.
$('#chatInput').on('focus', UI.updateFocusIndicator).on('blur', UI.updateFocusIndicator);
$('#game-display').on('focus', UI.updateFocusIndicator).on('blur', UI.updateFocusIndicator);
// Make game display focusable
// tabindex -1 makes the game display programmatically focusable without entering tab order.
$('#game-display').attr('tabindex', '-1');
},
setupKeyboardControls: function() {
const self = this; // Store reference to InputHandler for use in event handler
const self = this; // The keydown handler runs with its own `this`, so capture InputHandler.
$(document).on('keydown', function(e) {
// Handle ESC to leave spectate mode
if (e.key === 'Escape' && typeof SocketHandlers !== 'undefined' && SocketHandlers._spectatorMode) {
e.preventDefault();
if (window.socket) {
@@ -127,9 +117,8 @@ const InputHandler = {
if (document.activeElement === $('#game-display')[0]) {
if (Game && Game._gameActive) {
// Block movement input if spectating
// Spectators drive no character, so movement keys are ignored.
if (typeof Game !== 'undefined' && Game._isSpectating) {
// In spectator mode - ignore movement keys
return;
}
@@ -146,17 +135,16 @@ const InputHandler = {
}
if (moved && direction) {
e.preventDefault(); // Prevent page scrolling
// Implement movement throttling to prevent rapid-fire movement
e.preventDefault(); // Arrow keys would otherwise scroll the page.
// Moves are throttled to one per _moveCooldown; a move arriving inside the
// window replaces any pending one so only the latest direction is sent.
const now = Date.now();
if (now - self._lastMoveTime >= self._moveCooldown) {
// Send move immediately if enough time has passed
socket.emit('player_move', { direction });
self._lastMoveTime = now;
self._pendingMove = null;
} else {
// Queue the move to be sent after cooldown
self._pendingMove = { direction };
const timeToWait = self._moveCooldown - (now - self._lastMoveTime);
setTimeout(() => {
@@ -169,10 +157,9 @@ const InputHandler = {
}
}
} else {
// Game display has focus but game is not active (welcome screen)
// Only handle animation toggle
// Game display has focus with no active game (welcome/waiting screen): the only
// key handled is the animation toggle.
if (e.key === 'A' || e.key === 'a') {
// Toggle animation on waiting screen
e.preventDefault();
if (typeof ScreenManager !== 'undefined') {
ScreenManager.toggleAnimation();
@@ -181,9 +168,8 @@ const InputHandler = {
}
} else if (e.key === 'Enter' && document.activeElement !== $('#chatInput')[0]
&& !modalIsOpen() && !typingInAField()) {
// If Enter is pressed and chat is not focused
if (typeof Game !== 'undefined' && !Game._gameActive) {
// On welcome screen - start the game
// Enter on the welcome screen requests a game start.
e.preventDefault();
const isDebugMode = window.location.hostname === 'localhost' ||
@@ -197,17 +183,18 @@ const InputHandler = {
}
if (isDebugMode || ScreenManager.canEnterGame()) {
// Check if we should show waiting screen
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
const hasAddress = typeof SocketHandlers !== 'undefined' && SocketHandlers._hasPayoutAddress;
const canAfford = typeof SocketHandlers !== 'undefined' && SocketHandlers.canAffordGame();
const isFree = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'FREE';
// Only show waiting screen if address is set (if required) and they can afford it (if credits)
// Free play always waits; otherwise the player needs an affordable entry and,
// where required, a payout address on file.
const shouldShowWaiting = isFree || (canAfford && (!addressRequired || hasAddress));
if (shouldShowWaiting && typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) {
// Optimistically set awaiting payment if needed
// Paid entries show the awaiting-payment state up front, except when
// credits mode already has enough credits to cover the game.
const isPaidCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'PAID_CREDITS';
const hasEnoughCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._creditsBalance >= (SocketHandlers._creditsPerGame || 1);
@@ -223,11 +210,10 @@ const InputHandler = {
UI.scrollChat();
}
// Add visual feedback to chat
$('#messages').append($('<li style="color: #0f0;">').text("🔑 Game start requested..."));
UI.scrollChat();
} else {
// Game is active or in other state - focus chat
// With a game active, Enter moves focus to chat.
$('#chatInput').focus();
UI.updateFocusIndicator();
}
@@ -236,12 +222,11 @@ const InputHandler = {
},
setupClickHandlers: function() {
// Add click handling for the HTML START button
$('#startButton').click(function(e) {
// Show the wait-vs-drop-in TIMING choice whenever the game would start instantly (no
// upfront payment): free play (this instance allows it), a FREE instance, or paid-credits
// with credits in hand. The chosen payment method flows through auto_start / join_queue
// unchanged — this modal only picks WHEN you enter.
// The wait-vs-drop-in timing choice appears whenever the game would start instantly with
// no upfront payment: free play enabled, a FREE instance, or paid-credits with credits in
// hand. The modal only picks when you enter; the payment method still flows through
// auto_start / join_queue unchanged.
if (typeof SocketHandlers !== 'undefined') {
var mode = SocketHandlers._gameMode;
var freeAvailable = SocketHandlers._freePlayEnabled || mode === 'FREE';
@@ -253,27 +238,25 @@ const InputHandler = {
}
}
// Default: attempt immediate start (payment-required modes go through their own flow)
// Otherwise attempt an immediate start; payment-required modes go through their own flow.
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
if (!SocketHandlers.emitFairGameStart('auto_start')) return;
} else {
socket.emit('auto_start');
}
// Check if we should show waiting screen
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
const hasAddress = typeof SocketHandlers !== 'undefined' && SocketHandlers._hasPayoutAddress;
const canAfford = typeof SocketHandlers !== 'undefined' && SocketHandlers.canAffordGame();
const isFree = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'FREE';
// We only show waiting screen if:
// 1. It's free mode
// 2. We have address (if required) AND (it's direct pay OR we have credits)
// Free play always waits; otherwise the player needs an affordable entry and, where
// required, a payout address on file.
const shouldShowWaiting = isFree || (canAfford && (!addressRequired || hasAddress));
if (shouldShowWaiting) {
// If in a paid mode but no payment detected yet, optimistically set awaiting payment
// UNLESS it is credits mode and we already have enough credits
// Paid modes with no payment detected yet show the awaiting-payment state, except
// when credits mode already has enough credits to cover the game.
const isPaidCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'PAID_CREDITS';
const hasEnoughCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._creditsBalance >= (SocketHandlers._creditsPerGame || 1);
@@ -287,7 +270,7 @@ const InputHandler = {
ScreenManager.drawWaitingScreen();
}
} else {
// If not showing waiting screen, add helpful message to chat if missing address
// A missing payout address is the actionable case, so name it in chat.
if (addressRequired && !hasAddress) {
$('#messages').append($('<li style="color: #ffa500;">').text("⚠️ Please set a payout address using the button below before starting."));
UI.scrollChat();
@@ -304,7 +287,6 @@ const InputHandler = {
if ($panel.length && $panel.is(':visible')) {
$panel.hide();
} else {
// Request fresh game list and show panel
if (window.socket) {
socket.emit('get_active_games', { page: 1, pageSize: 20 });
}
@@ -314,24 +296,21 @@ const InputHandler = {
}
});
// Add click handling for the animation toggle button
$('#animationToggleButton').click(function(e) {
if (typeof ScreenManager !== 'undefined' && ScreenManager.toggleAnimation) {
ScreenManager.toggleAnimation();
// Redraw the waiting screen to reflect the change
// The waiting screen is drawn once, so it needs a redraw to pick up the new setting.
if (typeof ScreenManager.drawWaitingScreen === 'function') {
ScreenManager.drawWaitingScreen();
}
}
// Add visual feedback to chat
const status = ScreenManager._animationEnabled ? "enabled" : "disabled";
$('#messages').append($('<li style="color: #aa0;">').text(`🎬 Animation ${status}`));
UI.scrollChat();
});
// Address management button
$('#manageAddressButton').click(function(e) {
e.preventDefault();
if (window.socket) {
@@ -358,10 +337,9 @@ const InputHandler = {
}
};
// Ensure InputHandler is available globally
if (typeof window !== 'undefined') {
window.InputHandler = InputHandler;
}
// Note: InputHandler.init() is called from index.html after DOM ready
// to ensure proper initialization order with other modules
// index.html calls InputHandler.init() after DOM ready, once the modules it reaches into
// (UI, Game, ScreenManager, SocketHandlers) are defined.
+3 -3
View File
@@ -164,7 +164,7 @@ const SmirkAuth = {
// Sign directly. If this origin doesn't yet hold the Nostr scope, the wallet throws
// NOT_AUTHORIZED (no popup); grant it once via getNostrPublicKey() and retry. Signing
// first means a RETURNING user (scope already granted) gets a SINGLE approval instead of
// two. The wallet still asks per-signature for kind 27235 — that can't be silenced.
// two. The wallet still asks per-signature for kind 27235; that can't be silenced.
let signed;
try {
signed = await window.smirk.signNostrEvent(evt);
@@ -267,7 +267,7 @@ const SmirkAuth = {
}
try {
// /status is session-gated (BOLA fix) — send the session token so a linked user
// /status is session-gated (BOLA fix): send the session token so a linked user
// is recognised; without it the endpoint returns 401 and we fall through to unlinked.
var token = '';
try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) { token = ''; }
@@ -366,7 +366,7 @@ const SmirkAuth = {
this._updateButton(btn, 'error'); // button label becomes "Retry Smirk Connect"
// A locked wallet is an action, not a red error. Current Smirk builds auto-open the
// unlock popup on connect()/signNostrEvent(); older builds throw LOCKED — so guide the
// unlock popup on connect()/signNostrEvent(); older builds throw LOCKED, so guide the
// user to unlock and retry (the Retry button re-invokes connect(), which pops the
// unlock screen on the updated build).
const locked = (err && err.code === 'LOCKED') || /is locked/i.test((err && err.message) || '');
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
// AsciiRenderer — draws a Scene as a monospace glyph grid on a 2D canvas.
// AsciiRenderer: draws a Scene as a monospace glyph grid on a 2D canvas.
// The always-available fallback tier: tiny, legible, accessible.
(function (root) {
'use strict';
@@ -111,7 +111,7 @@
}
}
// Camera focus — centre the SP camera on the player cell (same convention as the tiled
// Camera focus: centre the SP camera on the player cell (same convention as the tiled
// renderer). Sticky: only update when the player is present, so switching TO ascii mid-game
// doesn't leave focusPoint null (which pinned the whole grid to the corner → black screen).
var focusFallback = null;
+61 -62
View File
@@ -45,10 +45,10 @@
data = data || {};
var prev = RK.entitlements || {};
// Consume the server-authoritative catalog (migration 024). This retires the hardcoded
// client pack list as the source of truth: operator-added packs appear, and existing packs
// pick up server definitions (label/projection/tier). Merged (not replaced) so any client
// fallback a render mode relies on is preserved if the operator's catalog omits it.
// The server catalog is authoritative over the client pack list: operator-added packs
// appear, and existing packs pick up server definitions (label/projection/tier). Merged
// rather than replaced so a client fallback a render mode relies on survives an operator
// catalog that omits it.
if (Array.isArray(data.catalog) && data.catalog.length) {
var merged = {};
for (var k in RK.PACKS) merged[k] = RK.PACKS[k];
@@ -130,15 +130,15 @@
};
RK.THEMES = {
// FREE baseline pack — the game's original bare tiles. Only the essentials are mapped
// (floor/wall/door/torch/features); furniture kinds (bar/keg/table/chair/…) are absent, so
// they fall back to floor and the free tavern reads plain — the intended "reason to unlock".
// Free baseline pack: the game's bare tiles. Only the essentials are mapped
// (floor/wall/door/torch/features); furniture kinds (bar/keg/table/chair, ...) are absent,
// so they fall back to floor and the free tavern reads plain, which is the reason to unlock.
'original': {
id: 'original',
label: 'Original Tiles',
// The game's ORIGINAL tileset (html/tiles.png, 32px, no spacing) — the exact tiles the
// ROT instructions + waiting screens use, so the free pack IS the classic look. Grid
// coords = the legacy pixel coords / 32 (see options.js tileMap).
// html/tiles.png, 32px, no spacing: the same tiles the ROT instructions and waiting
// screens use, so the free pack carries the classic look. Grid coords are the legacy
// pixel coords divided by 32 (see options.js tileMap).
tileset: { url: 'tiles.png', tile: 32, spacing: 0 },
tiles: {
floor: [0, 0],
@@ -149,58 +149,57 @@
exit: [1, 0],
treasure: [30, 0], // $W gold coin
torch: [32, 1],
// Classic entities too (the tiled renderer prefers a pack's player/monster tile over
// the sprite when present) — the blue-robe @ hero and the green ~ goblin.
// The tiled renderer prefers a pack's player/monster tile over the sprite when
// present: the blue-robe @ hero and the green ~ goblin.
player: [2, 0],
monster: [31, 1]
// No furniture kinds → tavern furniture falls back to floor (bare, as intended).
// No furniture kinds, so tavern furniture falls back to floor.
}
},
'roguelike-interior': {
id: 'roguelike-interior',
label: 'Kenney Roguelike Interior',
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
// Default tile picks from the sheet metadata ( roguelikeSheet_meta.json).
// Default tile picks from the sheet metadata (roguelikeSheet_meta.json).
// The tile-picker overlay can override these per-user via localStorage.
// Each kind maps to a single [col,row] on the sheet (cols = 57). Per-cell "variant"
// arrays were tried but mixing wood/stone/plank tiles per cell read as noise, so we
// keep one coherent tile per kind. (TileRenderer still accepts an array here if a
// future theme wants subtle, same-material variation.)
// Each kind maps to a single [col,row] on the sheet (cols = 57). One coherent tile per
// kind: mixing wood/stone/plank variants per cell reads as noise. TileRenderer still
// accepts an array here for a theme wanting subtle, same-material variation.
tiles: {
floor: [5, 2], // 119 — wood floor
floor2: [6, 2], // 120 — stone floor (accent)
wall: [13, 12], // 697 — stone masonry
floor: [5, 2], // 119 wood floor
floor2: [6, 2], // 120 stone floor (accent)
wall: [13, 12], // 697 stone masonry
// Tavern furnishings (coords from roguelikeSheet_meta.json labels: index -> [i%57, i/57]).
window: [42, 2], // 156 — window (in the wall)
bar: [29, 0], // 29 — bar counter
keg: [23, 0], // 23 — barrel / beer keg (behind the bar)
shelf: [30, 1], // 87 — shelf of bottles (behind the bar)
table: [20, 3], // 191 — table (191 reads wooden; 312 rendered green)
chair: [19, 3], // 190 — chair
barrel: [23, 0], // 23 — barrel (decor)
crate: [25, 0], // 25 — crate (decor)
rug: [11, 16], // 923 — rug
door: [37, 9], // 550 — door
window: [42, 2], // 156 window (in the wall)
bar: [29, 0], // 29 bar counter
keg: [23, 0], // 23 barrel / beer keg (behind the bar)
shelf: [30, 1], // 87 shelf of bottles (behind the bar)
table: [20, 3], // 191 table, reads wooden; 312 renders green
chair: [19, 3], // 190 chair
barrel: [23, 0], // 23 barrel (decor)
crate: [25, 0], // 25 crate (decor)
rug: [11, 16], // 923 rug
door: [37, 9], // 550 door
// Dungeon features drawn as real tiles instead of monospace glyphs.
entrance: [37, 9], // 550 — door
exit: [37, 9], // 550 — door
treasure: [42, 15], // 897 — item
torch: [50, 10] // 620 — rubble / prop
entrance: [37, 9], // 550 door
exit: [37, 9], // 550 door
treasure: [42, 15], // 897 item
torch: [50, 10] // 620 rubble / prop
}
},
// Second topdown pack — same sheet, a DUNGEON palette (stone floors, barrels/crates instead
// of the wood-interior furniture). A distinct look with zero new assets, and it's the
// "interchangeable dungeon" style (the tiled renderer's lighting/shadows are the built-in FX).
// Second topdown pack: same sheet, dungeon palette (stone floors, barrels/crates instead of
// the wood-interior furniture). A distinct look with zero new assets; the tiled renderer's
// lighting/shadows supply the FX.
'roguelike-dungeon': {
id: 'roguelike-dungeon',
label: 'Roguelike Dungeon',
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
fx: true,
tiles: {
floor: [6, 2], // 120 — stone floor
floor2: [13, 17], // 982 — plank accent
wall: [13, 12], // 697 — masonry
window: [42, 2], // 156 — window
floor: [6, 2], // 120 stone floor
floor2: [13, 17], // 982 plank accent
wall: [13, 12], // 697 masonry
window: [42, 2], // 156 window
bar: [23, 0], // barrels (a dungeon has no bar)
keg: [23, 0],
shelf: [25, 0], // crate
@@ -315,15 +314,15 @@
tile: { w: 84, h: 42, imageW: 92, imageH: 184 },
tiles: {
// Floors / ground
floor: 'assets/kenney/iso-dungeon/tiles/planks_S.png', // wood — tavern interior
floor2: 'assets/kenney/iso-dungeon/tiles/stone_S.png', // stone — dungeon
floor: 'assets/kenney/iso-dungeon/tiles/planks_S.png', // wood, tavern interior
floor2: 'assets/kenney/iso-dungeon/tiles/stone_S.png', // stone, dungeon
dirt: 'assets/kenney/iso-dungeon/tiles/dirt_S.png',
rug: 'assets/kenney/iso-dungeon/tiles/planks_S.png',
// Architecture (full-height tiles). Iso walls have a facing: a wall running along the
// grid-x axis needs the _S rotation, one running along grid-y needs the perpendicular
// _W rotation, and true corners get the corner tile. The renderer picks per-cell from
// neighbours, choosing `<kind>Y` for y-running segments (that's what fixes the "thin
// slabs" look on the top-left / bottom-right edges).
// neighbours, choosing `<kind>Y` for y-running segments; without that the top-left and
// bottom-right edges read as thin slabs.
wall: 'assets/kenney/iso-dungeon/tiles/stoneWall_S.png',
wallY: 'assets/kenney/iso-dungeon/tiles/stoneWall_W.png',
wallCorner: 'assets/kenney/iso-dungeon/tiles/stoneWallCorner_S.png',
@@ -332,15 +331,15 @@
door: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_S.png',
doorY: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_W.png',
archway: 'assets/kenney/iso-dungeon/tiles/stoneWallArchway_S.png',
// Floor variety — the renderer sprinkles these deterministically so the ground isn't a
// Floor variety: the renderer sprinkles these deterministically so the ground isn't a
// flat sea of identical planks.
floorAlt: 'assets/kenney/iso-dungeon/tiles/planksBroken_S.png',
floor2Alt: 'assets/kenney/iso-dungeon/tiles/stoneUneven_S.png',
stoneTile: 'assets/kenney/iso-dungeon/tiles/stoneTile_S.png',
column: 'assets/kenney/iso-dungeon/tiles/stoneColumnWood_S.png',
stairs: 'assets/kenney/iso-dungeon/tiles/stairs_S.png',
// Furniture props (drawn over a floor base) — each kind now has distinct art.
bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png', // stacked barrels = the bar counter
// Furniture props, drawn over a floor base; each kind has distinct art.
bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png', // stacked barrels as the bar counter
keg: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
barrel: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
table: 'assets/kenney/iso-dungeon/tiles/tableShortChairs_S.png', // real table + chairs
@@ -350,11 +349,11 @@
chest: 'assets/kenney/iso-dungeon/tiles/chestClosed_S.png',
fallback: 'assets/kenney/iso-dungeon/tiles/planks_S.png'
},
// The Kenney files named Male_0..Male_7 are directional renders of the same body,
// not different character classes. The customizer exposes one honest Iso body for now.
// These sprites are angled CLOCKWISE from NE: 0=NE, 2=SE, 4=SW, 6=NW (verified against live
// movement). In this iso projection a grid step moves visually up->NE, down->SW, left->NW,
// right->SE, so each facing uses the sprite pointing that way — face the way you move.
// The Kenney files named Male_0..Male_7 are directional renders of the same body, not
// different character classes, so the customizer exposes one Iso body. The sprites are
// angled clockwise from NE: 0=NE, 2=SE, 4=SW, 6=NW. In this iso projection a grid step
// moves visually up->NE, down->SW, left->NW, right->SE, so each facing uses the sprite
// pointing that way and the character faces its direction of travel.
character: isoMale(3), // idle faces roughly toward the camera (S)
directions: {
up: isoMale(0), // NE (up-right)
@@ -368,10 +367,10 @@
}
};
// Second iso pack — Kenney Isometric Medieval Town (a coherent, DIFFERENT environment). Strong on
// architecture (floor/walls/door/window/banner); no medieval furniture at this scale, so furniture
// kinds fall back to floor for now. Reuses the dungeon-pack characters. Its tiles are shorter
// (210x244) than the dungeon set, so its own tile geometry.
// Second iso pack: Kenney Isometric Medieval Town, a separate environment. Strong on
// architecture (floor/walls/door/window/banner); it has no medieval furniture at this scale, so
// furniture kinds fall back to floor. Reuses the dungeon-pack characters. Its tiles are shorter
// (210x244) than the dungeon set, hence its own tile geometry.
RK.isoMedievalAssets = {
pack: 'iso-medieval',
avatar: 'char-villager',
@@ -408,10 +407,10 @@
}
};
// Register the built-in RENDER packs in the pack registry (packRegistry.js) so iso/3D become
// multi-pack like topdown themes already are. Each maps to its catalog id; the active pack per
// projection is entitlement-gated + user-selectable. Adding a pack later = registerPack(...) +
// assets + a catalog row. Backward-compatible: one pack per projection resolves to these.
// Register the built-in render packs in the pack registry (packRegistry.js) so iso and 3D are
// multi-pack like topdown themes. Each maps to its catalog id; the active pack per projection
// is entitlement-gated and user-selectable. Adding a pack takes registerPack(...), assets, and
// a catalog row. With one pack per projection, resolution falls through to these.
if (RK.registerPack) {
RK.registerPack({ id: 'original', label: 'Original Tiles', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['original'] });
RK.registerPack({ id: 'roguelike-interior', label: 'Roguelike Interior', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['roguelike-interior'] });
+1 -1
View File
@@ -1,4 +1,4 @@
// TilesetAtlas — loads a tileset image and serves individual tiles to either a 2D canvas
// TilesetAtlas: loads a tileset image and serves individual tiles to either a 2D canvas
// (TileRenderer) or PixiJS (FancyRenderer). Source-agnostic: any pack's sheet loads through it.
(function (root) {
'use strict';
+1 -1
View File
@@ -91,7 +91,7 @@
// A player who never customized arrives as avatar 'default' (or an unknown id), which is not
// a char/skin/model and would fall to the grey circle. On the TAVERN canvas path, coerce it
// to a real Kenney character so nobody is a grey dot in a room of people. But NOT on
// 'legacy-rot' (the single-player dungeon) — there RenderEngine falls back to the original @
// 'legacy-rot' (the single-player dungeon): there RenderEngine falls back to the original @
// hero TILE, which is what an un-customized player expects (grey circle was a canvas-only
// problem). iso/3d keep their own fallbacks below.
if (ap && projection !== 'iso' && projection !== '3d' && projection !== 'legacy-rot') {
+1 -1
View File
@@ -28,7 +28,7 @@
var frame = Math.floor((now || 0) / FRAME_MS) % FRAMES;
var sx = frame * FW;
// The cat occupies only ~40% of its 50x50 frame (lots of transparent padding), so scale
// the frame well past the cell and centre it — the visible cat then reads about tile-sized.
// the frame well past the cell and centre it; the visible cat then reads about tile-sized.
var size = cell * 2.7;
var dx = e.x * cell + (cell - size) / 2;
var dy = e.y * cell + (cell - size) / 2 + cell * 0.28; // centred, nudged down onto the tile
+12 -15
View File
@@ -1,9 +1,7 @@
// ChainAmbience — the live chain, drifting around the edges of a stage.
// ChainAmbience: the live chain, drifting around the edges of a stage.
//
// The Tavern sits on top of a real blockchain, and the game's whole clock is "when does the next
// block land". This makes that visible without putting a dashboard on screen: fragments of the top
// block hash fade in around the border and drift away, a corner readout tracks the height, and a
// new block sends a ripple around the frame.
// Fragments of the top block hash fade in around the border and drift away, a corner readout
// tracks the height, and a new block sends a ripple around the frame.
//
// Purely decorative. It reads the public `blockheight` broadcast and never influences gameplay, so
// a missing or stale tip just means fewer motes.
@@ -85,11 +83,10 @@
var self = this;
this.timer = root.setInterval(function () { self._spawn(); }, this.spawnMs);
// A hidden tab pauses CSS animations, so `animationend` never fires and nothing is ever
// removed — while the spawn interval keeps ticking. Left alone, a tavern tab parked in the
// background accrues motes indefinitely and then plays a pile of stale ones all at once when
// the player comes back. Spawning is skipped while hidden (below) and the backlog is dropped
// here; these are decorations, so discarding them costs nothing.
// A hidden tab pauses CSS animations, so `animationend` never fires and motes are never
// removed. Spawning is skipped while hidden and any backlog is dropped here, otherwise a
// backgrounded tab accrues motes indefinitely and replays them all at once on return.
// These are decorations, so discarding them costs nothing.
this._onVisibility = function () {
if (!self.el || !self.doc.hidden) return;
self._clearMotes();
@@ -121,9 +118,9 @@
ChainAmbience.prototype._spawn = function (burst) {
if (!this.el || reducedMotion()) return;
if (this.doc.hidden) return; // paused animations would never clean themselves up
// Count the DOM, not a counter: a missed animationend must not permanently inflate the tally
// and silently stop all further spawning.
if (this.doc.hidden) return; // paused animations never clean themselves up
// Count the DOM, not `this.motes`: a missed animationend would permanently inflate the
// tally and silently stop all further spawning.
if (!burst && this.el.querySelectorAll('.rk-cm').length >= this.maxMotes) return;
var text = this._fragment();
if (!text) return;
@@ -132,8 +129,8 @@
m.className = 'rk-cm rk-drift';
m.textContent = text;
// Hug the border: pick an edge, then a position along it. The middle of the stage is where
// the game is — the chain lives in the margins.
// Hug the border: pick an edge, then a position along it. The middle of the stage belongs
// to the game, so the chain lives in the margins.
var edge = Math.floor(Math.random() * 4);
var along = 6 + Math.random() * 84;
var dx = 0, dy = 0;
+2 -2
View File
@@ -725,7 +725,7 @@
}
// Sprite tinting (cloth / skin / hair / gear colour) is disabled for the top-down
// Tiles view — characters render in native Kenney art. Only equipment PIECE selection
// Tiles view: characters render in native Kenney art. Only equipment PIECE selection
// remains here; the '@' colour for ASCII mode is still pickable in the ascii/colour
// branch above.
sectionTitle('Equipment', editSection);
@@ -791,7 +791,7 @@
}
function buildAvatarGrid() {
// Don't auto-switch the avatar when the render mode changes — only an explicit
// Don't auto-switch the avatar when the render mode changes; only an explicit
// click on a different avatar card should change the saved identity. If the current
// avatar isn't native to this projection, it stays selected (the resolver handles
// fallback rendering) so browsing render modes never silently mutates the identity.
+1 -1
View File
@@ -229,7 +229,7 @@
// Sprite recoloring (skin / hair / cloth-base / gear tinting) is intentionally disabled:
// characters and equipment render in their native Kenney art. Returning null makes every
// caller (drawCharTileCanvas, charTileTexture) fall through to the untinted sprite. The
// ASCII '@' colour is unaffected — it uses colors.base via avatarVisuals.tintColorFor,
// ASCII '@' colour is unaffected: it uses colors.base via avatarVisuals.tintColorFor,
// a separate path. To restore tinting, delete this early return.
return null;
var a = RK.charAtlas();
+1 -1
View File
@@ -1,4 +1,4 @@
// Designed room — loads an imported Tiled room (from tmx_import.py): layered tile-index grids
// Designed room: loads an imported Tiled room (from tmx_import.py): layered tile-index grids
// + a tileset, and draws the layers (back-to-front) beneath the avatars. The server uses the
// same room's walkability/dims for collision, so visuals and movement match.
(function (root) {
+3 -3
View File
@@ -1,5 +1,5 @@
// FancyRenderer — WebGL tier via PixiJS (v7). Beyond flat shapes: a real blurred bloom on
// entities, warm flickering lights along the bar, drifting dust motes, and a vignette — all
// FancyRenderer: WebGL tier via PixiJS (v7). Beyond flat shapes: a real blurred bloom on
// entities, warm flickering lights along the bar, drifting dust motes, and a vignette, all
// programmatic (no art assets). Requires a global PIXI (loaded from CDN, with @pixi/unsafe-eval
// so shaders compile under a strict CSP); the factory falls back to tiled if PIXI is missing.
(function (root) {
@@ -77,7 +77,7 @@
FancyRenderer.prototype._buildTiles = function (scene) {
this.tileLayer.removeChildren();
var cell = this.cell;
// A designed room is loaded — draw its layered tiles as sprites from the room atlas.
// A designed room is loaded: draw its layered tiles as sprites from the room atlas.
if (window.RK && RK.roomReady && RK.roomReady()) {
var d = RK.activeRoom.desc, a = RK.activeRoom.atlas;
for (var li = 0; li < d.layerOrder.length; li++) {
+2 -2
View File
@@ -1,8 +1,8 @@
// fxLayer.js — the shared "juice" engine (RK.fx). Pure-canvas animated FX (no WebGL, no shaders,
// fxLayer.js: the shared "juice" engine (RK.fx). Pure-canvas animated FX (no WebGL, no shaders,
// CSP-safe) used by BOTH the top-down tiled renderer and the iso renderer so a scene lights up the
// same way in either projection: dancing torch/hearth flame + warm glow, and pulsing hazard tiles
// (lava / poison / spikes) for the dungeon. Everything is a function of `now` (ms) so callers just
// keep a RAF loop alive and pass the clock — no per-particle state to thread through.
// keep a RAF loop alive and pass the clock: no per-particle state to thread through.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
+59 -63
View File
@@ -1,4 +1,4 @@
// IsoRenderer — canvas isometric projection using Kenney's Isometric Miniature Dungeon pack.
// IsoRenderer: canvas isometric projection using Kenney's Isometric Miniature Dungeon pack.
// It draws the same Scene as the top-down renderers, preserving the shared Room contract.
(function (root) {
'use strict';
@@ -75,7 +75,8 @@
};
// Map a dungeon FEATURE entity (entrance/exit/treasure/item) to a pack tile: stairs for the
// entrance/exit, a chest for treasure/items. Null if the pack has no fitting tile (→ glyph).
// entrance/exit, a chest for treasure/items. Null if the pack has no fitting tile, which falls
// back to a glyph.
IsoRenderer.prototype._featureTileUrl = function (e) {
var tiles = this.assets.tiles || {};
var c = e.char;
@@ -84,8 +85,8 @@
return null;
};
// A stroked iso diamond outline — used to flag the floor cells the player can actually step to,
// so ambiguous wall gaps are never mistaken for openings.
// Stroked iso diamond outline, used to flag the floor cells the player can actually step to so
// ambiguous wall gaps are never mistaken for openings.
IsoRenderer.prototype._diamondOutline = function (cx, cy, color) {
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
ctx.save();
@@ -109,8 +110,8 @@
ctx.restore();
};
// A flat iso diamond in the legend colour — the placeholder a tile draws while its pack image
// loads (or if an image is missing), so the view shows the dungeon shape instead of going black.
// Flat iso diamond in the legend colour: the placeholder a tile draws while its pack image loads
// (or if an image is missing), so the view shows the dungeon shape instead of going black.
IsoRenderer.prototype._diamond = function (cx, cy, color) {
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
ctx.beginPath();
@@ -132,13 +133,13 @@
ctx.restore();
};
// Warm pool of light cast onto the scene by a torch / the player's lantern / a beacon. Drawn
// additively after the tiles so it lights the floor, the props AND the characters standing in
// Warm pool of light cast onto the scene by a torch, the player's lantern or a beacon. Drawn
// additively after the tiles so it lights the floor, the props and the characters standing in
// it, which is what separates a lit room from a flat sprite collage.
IsoRenderer.prototype._lightPool = function (cx, cy, radius, inner, outer) {
var ctx = this.ctx;
// A non-finite emitter would throw out of createRadialGradient and abandon the frame
// half-drawn. Lighting is decoration — skip the bad pool, keep the dungeon.
// A non-finite emitter throws out of createRadialGradient and abandons the frame half-drawn.
// Lighting is decoration, so skip the bad pool and keep the dungeon.
if (!isFinite(cx) || !isFinite(cy) || !isFinite(radius) || radius <= 0) return;
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
g.addColorStop(0, inner);
@@ -177,7 +178,7 @@
ctx.restore();
};
// Crevice darkening on floor cells that touch a wall — a cheap ambient-occlusion cue. The
// Crevice darkening on floor cells that touch a wall: a cheap ambient-occlusion cue. The
// gradient is clear in the middle of the cell and dark at its rim, so tiles tucked into a corner
// sit in shadow instead of looking like stickers laid on top of the ground.
IsoRenderer.prototype._wallAO = function (cx, cy, strength) {
@@ -195,8 +196,8 @@
ctx.restore();
};
// Warm radial vignette — an ambient tavern glow that darkens the edges and lifts the centre,
// the "juice" that makes the flat canvas feel lit rather than pasted.
// Warm radial vignette: an ambient tavern glow that darkens the edges and lifts the centre, so
// the flat canvas feels lit rather than pasted.
IsoRenderer.prototype._vignette = function () {
var ctx = this.ctx, w = this.canvas.width, h = this.canvas.height;
var g = ctx.createRadialGradient(w / 2, h * 0.46, Math.min(w, h) * 0.18,
@@ -265,12 +266,10 @@
return cv;
};
// Darkened copy of a tile image, cached at quantized levels.
//
// Fog-of-war used to dim tiles with globalAlpha, which made remembered walls TRANSLUCENT — you
// could see the tiles behind them, so the dungeon read as a haze of overlapping ghosts instead
// of solid stone in the dark. Multiplying the sprite's own pixels toward black keeps every tile
// opaque and just unlit, which is what "you remember this room, it isn't lit" should look like.
// Darkened copy of a tile image, cached at quantized levels. Fog-of-war multiplies the sprite's
// own pixels toward black rather than dimming with globalAlpha: alpha makes remembered walls
// translucent, so the tiles behind them show through and the dungeon reads as a haze of
// overlapping ghosts. Shading keeps every tile opaque and merely unlit.
IsoRenderer.prototype._shadedImage = function (img, level) {
if (!img) return img;
var step = Math.max(0, Math.min(5, Math.round(level * 5)));
@@ -284,7 +283,7 @@
var ctx = cv.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.globalCompositeOperation = 'source-atop';
// A touch of blue in the shadow rather than flat black — unlit stone reads cold.
// A touch of blue in the shadow rather than flat black, so unlit stone reads cold.
ctx.fillStyle = 'rgba(6,10,20,' + (1 - step / 5).toFixed(3) + ')';
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.globalCompositeOperation = 'source-over';
@@ -307,7 +306,7 @@
return tiles[kind] || tiles.fallback || tiles.floor;
};
// Deterministic per-cell hash (no RNG — stable across frames/reloads).
// Deterministic per-cell hash: no RNG, so it is stable across frames and reloads.
function cellHash(x, y) {
var h = (x * 73856093) ^ (y * 19349663);
h = (h ^ (h >>> 13)) >>> 0;
@@ -324,8 +323,8 @@
// Iso walls have a facing. A segment running along grid-x uses the base (_S) rotation; one
// running along grid-y uses the perpendicular (`<kind>Y`, _W) rotation; a true corner (both
// axes present) uses the corner tile. This is what makes enclosing walls read as continuous
// faces instead of thin disconnected slabs on the y-running edges.
// axes present) uses the corner tile. Without the per-axis rotation, enclosing walls read as
// thin disconnected slabs on the y-running edges instead of continuous faces.
IsoRenderer.prototype._wallVariant = function (scene, x, y, kind) {
var tiles = this.assets.tiles || {};
var xRun = this._isWall(scene, x - 1, y) || this._isWall(scene, x + 1, y);
@@ -361,9 +360,8 @@
var key = e.id || 'anon';
var st = this.last[key] || (this.last[key] = { x: e.x, y: e.y, t: 0, facing: 'down' });
if (st.x !== e.x || st.y !== e.y) {
// Infer facing from the actual movement delta — the SP game doesn't send player.facing
// (only the tavern does), so without this the character always faced 'down' (SW) no matter
// which way it moved. Explicit e.facing (tavern) still wins below.
// Facing is inferred from the movement delta because the SP game does not send
// player.facing; only the tavern does. Explicit e.facing (tavern) wins below.
var ddx = e.x - st.x, ddy = e.y - st.y;
if (Math.abs(ddx) >= Math.abs(ddy)) st.facing = ddx > 0 ? 'right' : 'left';
else st.facing = ddy > 0 ? 'down' : 'up';
@@ -386,10 +384,10 @@
var originY = margin + this.imageH;
var wantW = Math.ceil((scene.cols + scene.rows) * this.tileW / 2 + margin * 2 + this.tileW * 2);
var wantH = Math.ceil((scene.cols + scene.rows) * this.tileH / 2 + this.imageH + margin * 2);
// Only ASSIGN width/height when they actually change. Assigning either one reallocates and
// Assign width/height only when they actually change. Assigning either one reallocates and
// clears the whole backing buffer, and this renderer re-runs every animation frame (the
// walkable pulse and the flames keep `_animating` set) — so the old unconditional assignment
// was throwing away and re-allocating a multi-megapixel canvas 60 times a second.
// walkable pulse and the flames keep `_animating` set), so an unconditional assignment
// re-allocates a multi-megapixel canvas 60 times a second.
if (this.canvas.width !== wantW || this.canvas.height !== wantH) {
this.canvas.width = wantW;
this.canvas.height = wantH;
@@ -415,8 +413,8 @@
if (PROP[kind]) {
items.push({ type: 'prop', kind: kind, x: x, y: y, sx: p.x, sy: p.y, depth: x + y + 0.25 });
}
// Fire fixtures (torch/hearth) & hazard tiles (lava/poison/spikes): a floor tile is
// the base (drawn above via fallback); RK.fx paints the animated flame / pulse.
// Fire fixtures (torch/hearth) and hazard tiles (lava/poison/spikes): a floor tile is
// the base, drawn above via fallback; RK.fx paints the animated flame or pulse.
if (def && (def.fx === 'fire' || def.hazard)) {
items.push({ type: 'fx', def: def, x: x, y: y, sx: p.x, sy: p.y, depth: x + y + 0.3 });
}
@@ -426,21 +424,21 @@
var e = scene.entities[i];
p = this._project(e.x, e.y, originX, originY);
items.push({ type: 'entity', e: e, sx: p.x, sy: p.y, depth: e.x + e.y + 0.55 });
// Sticky camera target (keep last if the player is momentarily absent). Multiplayer
// scenes explicitly mark one racer as cameraTarget; never let a later rival steal it.
// Sticky camera target: the last value is kept if the player is momentarily absent.
// Multiplayer scenes mark one racer as cameraTarget so a later rival cannot steal it.
if (e.you || e.cameraTarget) { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
}
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
var now = Date.now();
// Light emitters collected while drawing, applied additively in one pass afterwards so the
// pools land on the floor, the props AND the characters standing in them.
// pools land on the floor, the props and the characters standing in them.
var lights = [];
for (i = 0; i < items.length; i++) {
var it = items[i];
if (it.type === 'tile') {
// FOG OF WAR: never render unexplored cells. Without this they fell back to the floor
// tile (`_tileUrl('dark')` → tiles.floor) and revealed the ENTIRE map from move 0.
// Fog of war: unexplored cells are never rendered. They would otherwise fall back to
// the floor tile (`_tileUrl('dark')` resolves to tiles.floor) and reveal the whole map.
if (it.kind === 'dark') continue;
var tileKind = PROP[it.kind] ? 'floor' : it.kind;
if (WALLISH[tileKind]) tileKind = this._wallVariant(scene, it.x, it.y, tileKind);
@@ -453,11 +451,11 @@
it.x >= this._plx && it.y >= this._ply &&
(it.x + it.y) > (this._plx + this._ply) &&
((it.x - this._plx) + (it.y - this._ply)) <= 3;
// Fog fade: dim explored-but-distant tiles toward the (near-black) background so the
// remembered area melts into the dark with distance — parity with the 2D modes.
// Fog fade: dim explored-but-distant tiles toward the near-black background so the
// remembered area melts into the dark with distance, matching the 2D modes.
var lb = (scene.lightGrid && scene.lightGrid[it.y] && scene.lightGrid[it.y][it.x] != null) ? scene.lightGrid[it.y][it.x] : 1;
// Cutaway walls stay ALPHA-faded (you need to see the floor through them). Everything
// else is shaded opaque, so distant stone is dark stone rather than a ghost.
// Cutaway walls stay alpha-faded so the floor shows through them. Everything else is
// shaded opaque, so distant stone is dark stone rather than a ghost.
ctx.globalAlpha = cut ? 0.28 : 1;
if (rec && rec.ready) {
var img = cut ? rec.img : this._shadedImage(rec.img, Math.max(0.16, lb));
@@ -466,11 +464,11 @@
ctx.globalAlpha = cut ? 0.28 : Math.max(0.16, lb);
this._diamond(it.sx, it.sy + this.tileH * 0.5, (legend[it.kind] && legend[it.kind].color) || '#3a4048');
}
// Walkable-floor pip: mark every visible floor cell with a soft dot so the WALKABLE
// ground is explicit everywhere. A dark gap with no pip is unambiguously NOT floor —
// that's what stops iso wall-gaps/voids from reading as holes you can walk into.
// Walkable-floor pip: every visible floor cell gets a soft dot so walkable ground is
// explicit everywhere. A dark gap with no pip is unambiguously not floor, which stops
// iso wall gaps and voids from reading as holes you can walk into.
if (it.kind === 'floor' || it.kind === 'floor2') {
// Crevice shadow where this floor cell abuts stone. Cheap AO — the more walls
// Crevice shadow where this floor cell abuts stone. Cheap AO: the more walls
// around it, the deeper the cell sits in shadow.
var adj = (this._isWall(scene, it.x - 1, it.y) ? 1 : 0) + (this._isWall(scene, it.x + 1, it.y) ? 1 : 0) +
(this._isWall(scene, it.x, it.y - 1) ? 1 : 0) + (this._isWall(scene, it.x, it.y + 1) ? 1 : 0);
@@ -513,10 +511,9 @@
});
}
} else if (it.type === 'entity') {
// Dungeon FEATURES/ITEMS (entrance/exit/treasure/items) are tiles/glyphs, NOT avatars.
// The iso renderer is shared with the tavern (where every entity is an avatar); without
// this branch the entrance was drawn as a male character — the "copy of the player at
// the start" the map origin showed.
// Dungeon features and items (entrance/exit/treasure/items) draw as tiles or glyphs,
// not avatars. The iso renderer is shared with the tavern, where every entity is an
// avatar, so without this branch the entrance draws as a character sprite.
if (it.e.kind === 'feature' || it.e.kind === 'item') {
var furl = this._featureTileUrl(it.e);
var frec = furl && this._load(furl);
@@ -524,14 +521,14 @@
else this._glyph(it.sx, it.sy + this.tileH * 0.4, it.e.char || '?', it.e.color || '#fff');
// Objectives beacon. Stairs and treasure are the only two things in the dungeon
// worth crossing a room for, so they get a slow breathing glow in their own
// colour — findable from across a dark hall without leaking the layout.
// colour: findable from across a dark hall without leaking the layout.
var beaconRGB = it.e.char === '<' ? '63,185,80'
: it.e.char === '>' ? '210,153,34'
: it.e.char && it.e.char.charAt(0) === '$' ? '251,191,36' : null;
if (beaconRGB) {
this._animating = true;
// NB: entity items carry the cell on `it.e`, not `it.x` (that is only set on
// tile items) — reading it.x here produced a NaN radius and threw.
// Entity items carry the cell on `it.e`; `it.x` is set only on tile items, so
// reading it here would give a NaN radius.
var breathe = 0.55 + Math.sin(now / 620 + it.e.x) * 0.45;
lights.push({
x: it.sx, y: it.sy + this.tileH, r: this.tileW * (1.1 + breathe * 0.35),
@@ -546,8 +543,8 @@
rec = this._load(frame && frame.url);
if (rec && rec.ready) {
var ch = frame.character || this.assets.character || {};
// Monsters use the same character art — tint them RED so they don't read as a
// second player following you around.
// Monsters use the same character art, so they are tinted red to avoid reading as
// a second player following you around.
var tint = it.e.kind === 'monster' ? '#f85149' : this._skinTintFor(frame.visual);
this._drawSpriteImage(this._tintedImage(rec.img, tint), it.sx, it.sy + this.tileH * 0.95, ch.imageH || 92);
} else {
@@ -573,9 +570,8 @@
}
// Walkability affordance: the iso wall tiles leave ambiguous gaps that read as doorways, so
// flag the floor cells the player can actually step to (orthogonally adjacent, walkable +
// explored) with a soft pulsing outline — a reliable "you can go here" cue independent of how
// the walls render.
// the floor cells the player can actually step to (orthogonally adjacent, walkable and
// explored) get a soft pulsing outline. That cue holds regardless of how the walls render.
if (this._plx != null) {
var pulse = 0.30 + Math.sin(now / 320) * 0.18;
var nbrs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
@@ -605,9 +601,9 @@
var L = lights[lg];
this._lightPool(L.x, L.y, L.r, L.inner, L.outer);
}
// Colour-temperature separation: everything the player can currently SEE is warm torchlight;
// the explored-but-remembered parts get a cold blue wash. Alpha alone made memory read as
// "far away"; the temperature shift makes it read as "remembered", which is what it is.
// Colour-temperature separation: everything the player can currently see is warm torchlight,
// while explored-but-remembered parts get a cold blue wash. Alpha alone reads as "far away";
// the temperature shift reads as "remembered".
this._memoryWash(scene, originX, originY);
this._vignette();
@@ -621,16 +617,16 @@
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
ctx.save();
ctx.globalCompositeOperation = 'source-atop';
// A DARK blue. A mid-tone blue at 20% actually lifted the already-darkened memory tiles
// brighter than the torchlit centre, inverting the whole depth hierarchy — remembered rooms
// glowed and the room you were standing in receded.
// The wash must be a dark blue. A mid-tone blue lifts the already-darkened memory tiles
// brighter than the torchlit centre, inverting the depth hierarchy so remembered rooms glow
// and the room the player stands in recedes.
ctx.fillStyle = 'rgba(16,28,52,1)';
for (var y = 0; y < scene.rows; y++) {
for (var x = 0; x < scene.cols; x++) {
var kind = scene.grid[y] && scene.grid[y][x];
if (!kind || kind === 'dark') continue;
var lb = (light[y] && light[y][x] != null) ? light[y][x] : 1;
if (lb >= 0.3) continue; // currently lit — leave it warm
if (lb >= 0.3) continue; // currently lit, so leave it warm
var p = this._project(x, y, originX, originY), cy = p.y + hh;
ctx.globalAlpha = 0.45 * (1 - lb / 0.3);
ctx.beginPath();
+2 -2
View File
@@ -1,8 +1,8 @@
// Pack registry — the engine behind "add more 2D tilesets / iso packs / 3D skins as products".
// Pack registry: the engine behind "add more 2D tilesets / iso packs / 3D skins as products".
//
// A PACK is a rich, self-contained render environment for a projection (topdown | iso | 3d). Because
// every scene (tavern OR dungeon) is kind-based (floor/wall/table/torch/monster…), the SAME pack
// renders both — packs are interchangeable across town and dungeon. Each pack maps 1:1 to a catalog
// renders both; packs are interchangeable across town and dungeon. Each pack maps 1:1 to a catalog
// entry (id/tier/threshold), so it's a monetizable product gated by entitlements.
//
// This module makes packs MULTI (like RK.THEMES already is for topdown) and adds active-pack
+3 -3
View File
@@ -18,11 +18,11 @@
return true;
};
// Render tiers. `premium` marks modes intended to be unlocked with credits — a cosmetic
// Render tiers. `premium` marks modes intended to be unlocked with credits: a cosmetic
// entitlement gated by Operator Policy, the same pattern as avatar unlocks. NOTE: rendering
// is client-side, so this is a soft/honour gate for cosmetics, not a hard security boundary.
// Modes are TECHNIQUES (how a scene is drawn). Packs are the SETS/styles within a projection
// (chosen via the pack picker) — so "Fancy" (tiles + FX) is a topdown PACK, not a mode. A
// (chosen via the pack picker), so "Fancy" (tiles + FX) is a topdown PACK, not a mode. A
// premium mode unlocks once the user has ANY unlocked pack for its projection.
RK.RENDER_MODES = [
{ id: 'tiles', label: 'Tiled', premium: false, projection: 'topdown' },
@@ -33,7 +33,7 @@
RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {} };
// Per-session QA bypass for premium render-mode gating (Fancy/Iso/3D). Default OFF. A tester
// opts IN for their own browser only via ?unlock=1 (persisted) — nobody else is affected:
// opts IN for their own browser only via ?unlock=1 (persisted); nobody else is affected:
// ?unlock=1 -> on (sticky) ?unlock=0 -> off
RK.RENDER_MODE_TEST_UNLOCKS = false;
try {
+27 -27
View File
@@ -9,21 +9,21 @@
// Tile kinds and how each renderer treats them (glyph for ASCII, colour for tiled/fancy).
// `over` names the ground tile an object sits on, so the renderer composites the object over it
// (its transparent areas show the floor/wall, not the dark canvas). Ground tiles (wall/floor/rug)
// have no `over` — they fill the cell.
// have no `over`; they fill the cell.
var TAVERN_LEGEND = {
wall: { char: '#', color: '#3a3f4b', solid: true },
window: { char: 'W', color: '#4a6a8a', solid: true, over: 'wall' },
floor: { char: '·', color: '#6b4d33', solid: false }, // warm wood, not the old dark green
floor: { char: '·', color: '#6b4d33', solid: false }, // warm wood
rug: { char: 'r', color: '#7a3b39', solid: false, over: 'floor' },
bar: { char: '=', color: '#6a4a2a', solid: true, over: 'floor' },
keg: { char: 'k', color: '#5a4028', solid: true, over: 'floor' },
shelf: { char: 'h', color: '#4a3a28', solid: true, over: 'floor' },
table: { char: 'T', color: '#6a4a2a', solid: true, over: 'floor' },
chair: { char: 'c', color: '#5a4028', solid: false, over: 'floor' }, // walkable — stand on it to "sit"
chair: { char: 'c', color: '#5a4028', solid: false, over: 'floor' }, // walkable: stand on it to "sit"
barrel: { char: 'B', color: '#5a4028', solid: true, over: 'floor' },
crate: { char: 'C', color: '#7a5a38', solid: true, over: 'floor' },
door: { char: 'D', color: '#3fb950', solid: false, over: 'floor' },
// Fire fixtures — the base tile is floor; RK.fx paints the animated flame + glow on top.
// Fire fixtures: the base tile is floor; RK.fx paints the animated flame + glow on top.
torch: { char: 'i', color: '#d29922', solid: true, over: 'floor', fx: 'fire', fxScale: 0.34 },
hearth: { char: 'F', color: '#e0742a', solid: true, over: 'floor', fx: 'fire', fxScale: 0.7 }
};
@@ -95,7 +95,7 @@
// ---- Dungeon game-state adapter ------------------------------------------------
// Converts a game state (from the server's game_start/game_update/spectator_update) into a
// renderer-agnostic Scene, so the render kit can draw the dungeon just like the tavern.
// This is the bridge for milestone R1 (main game → render kit) and T2b (tavern spectator).
// Used by both the main game and the tavern spectator view.
var DUNGEON_LEGEND = {
wall: { char: '#', color: '#2a2f38', solid: true },
@@ -107,8 +107,8 @@
// The generator only places torches on WALL cells adjacent to floor, so the fixture is
// wall-mounted: base on `wall`, draw the torch sprite over it, then RK.fx animates the flame.
torch: { char: 'i', color: '#d29922', solid: false, over: 'wall', fx: 'fire', fxScale: 0.34 },
// Hazard zones. The base tile stays walkable/ground; RK.fx paints the pulsing overlay. These
// render as soon as the dungeon generator emits their chars (L/P/^) in the tile stream.
// Hazard zones. The base tile stays walkable/ground; RK.fx paints the pulsing overlay.
// Keyed off the dungeon generator's chars (L/P/^) in the tile stream.
lava: { char: '≈', color: '#7a2a0e', solid: false, hazard: 'lava' },
poison: { char: '≈', color: '#1c4a24', solid: false, hazard: 'poison' },
spikes: { char: '^', color: '#3a3f48', solid: false, hazard: 'spikes' },
@@ -130,7 +130,7 @@ function dungeonTileKind(ch) {
return 'floor';
}
// Grid dimensions from array grids OR sparse {y:{x:v}} maps (the single-player client uses the
// Grid dimensions from array grids or sparse {y:{x:v}} maps (the single-player client uses the
// latter). Returns the max seen index + 1 across every supplied map.
function maxDims(maps) {
var rows = 0, cols = 0;
@@ -156,8 +156,8 @@ function maxDims(maps) {
}
function normalizeGameStateOpts(opts) {
// Older multiplayer callers passed a socket id directly. Keep that path working while the
// public API moves to an options object (`{ viewerId, playerAppearance, cryptoType }`).
// Accepts either a bare socket id (multiplayer callers) or an options object
// (`{ viewerId, playerAppearance, cryptoType }`).
if (typeof opts === 'string') return { viewerId: opts };
return (opts && typeof opts === 'object') ? opts : {};
}
@@ -188,7 +188,7 @@ function pickCameraPlayer(players, state, opts) {
p = players[i];
if (p && state.winnerId && p.id === state.winnerId) return p;
}
// Spectators follow a live contender by default, then gracefully fall back to the first racer.
// Spectators follow a live contender by default, then fall back to the first racer.
for (i = 0; i < players.length; i++) {
p = players[i];
if (p && p.alive !== false && !p.finished) return p;
@@ -219,7 +219,7 @@ function sceneFromGameState(state, opts) {
var visible = state.visibleTiles || {};
var explored = state.exploredTiles || {};
var lighting = state.lighting || {};
// Array grids expose .length; the SP client uses sparse {y:{x:v}} maps — fall back to the max
// Array grids expose .length; the SP client uses sparse {y:{x:v}} maps, so fall back to the max
// seen index. Explicit dungeonRows/Cols (server) always win.
var dims = maxDims([visible, explored, state.map]);
var rows = state.dungeonRows || visible.length || dims.rows;
@@ -228,8 +228,8 @@ function sceneFromGameState(state, opts) {
var matchPlayers = Array.isArray(state.players) ? state.players : [];
var cameraPlayer = pickCameraPlayer(matchPlayers, state, opts);
// Player cell — explored MEMORY fades to black with distance from here (see below). Multiplayer
// states use `players[]`, while the original single-player protocol uses singular `player`.
// Player cell. Explored memory fades to black with distance from here. Multiplayer states use
// `players[]`; the single-player protocol uses singular `player`.
var _pl = state.player || cameraPlayer || {};
var _plx = typeof _pl.x === 'number' ? _pl.x : (cols / 2);
var _ply = typeof _pl.y === 'number' ? _pl.y : (rows / 2);
@@ -256,9 +256,9 @@ function sceneFromGameState(state, opts) {
var la = (lighting[y] && lighting[y][x]) || 0;
lrow.push(Math.max(0.15, 1 - Math.min(la, 0.8)));
} else {
// Explored MEMORY (seen before, not currently visible): dim, and fading toward black
// with distance from the player, so the remembered area melts into the dark instead
// of forming a hard-edged grey block that outlines the map's shape/edge (a FoW leak).
// Explored memory (seen before, not currently visible): dim, fading toward black with
// distance from the player. A flat dim value would form a hard-edged grey block that
// outlines the map's shape, leaking information through the fog of war.
row.push(dungeonTileKind(ch));
var _dx = x - _plx, _dy = y - _ply;
lrow.push(Math.max(0, 0.26 - Math.sqrt(_dx * _dx + _dy * _dy) * 0.019));
@@ -272,11 +272,11 @@ function sceneFromGameState(state, opts) {
// Fog of war: a cell is `seen` once explored (grid isn't 'dark'); `inView` only while currently
// visible. Features (exit/treasure/entrance) show once discovered; the monster only shows while
// in view — otherwise the exit stairs leak through the fog before you've found them.
// in view, so its position does not leak through the fog.
function seen(x, y) { return !!(grid[y] && grid[y][x] && grid[y][x] !== 'dark'); }
function inView(x, y) { return !!(visible[y] && visible[y][x] !== undefined); }
// Entrance / exit / treasure as entities (so they layer above tiles) — only once explored.
// Entrance / exit / treasure as entities (so they layer above tiles), only once explored.
// Solo uses `[x,y]`; match state uses `{x,y}` for treasure, so accept both point shapes.
var entrance = pointOf(state.entrance);
var exit = pointOf(state.exit);
@@ -292,7 +292,7 @@ function sceneFromGameState(state, opts) {
entities.push({ id: 'treasure', x: treasure.x, y: treasure.y, kind: 'feature', char: tChar, color: '#fbbf24', label: null });
}
// Items — only once explored.
// Items, only once explored.
if (state.items) {
for (var key in state.items) {
if (state.items.hasOwnProperty(key)) {
@@ -304,7 +304,7 @@ function sceneFromGameState(state, opts) {
}
}
// Monster — only while it's actually in view.
// Monster, only while it is in view.
if (state.monster && inView(state.monster.x, state.monster.y)) {
entities.push({
id: 'monster', x: state.monster.x, y: state.monster.y,
@@ -331,13 +331,13 @@ function sceneFromGameState(state, opts) {
hasTreasure: !!rp.hasTreasure, placement: rp.placement == null ? null : rp.placement
});
}
// Original single-player protocol.
// Single-player protocol.
} else if (state.player) {
var playerEntity = {
id: 'player', x: state.player.x, y: state.player.y,
kind: 'player', char: '@', color: '#9aa4b2',
// No bogus default: the SP server doesn't send facing, so leave it null and let the iso/3D
// renderers INFER facing from movement. A hardcoded 'down' made them always face down/SW.
// The SP server does not send facing. Leaving it null lets the iso/3D renderers infer
// facing from movement; any hardcoded default would pin the sprite to one direction.
facing: state.player.facing || null, label: null,
you: !state.isSpectating, cameraTarget: true
};
@@ -369,9 +369,9 @@ function sceneFromGameState(state, opts) {
root.RK = root.RK || {};
root.RK.scene = api;
// Convenience aliases on RK directly — several callers (matchClient, tavern spectator, the SP
// render bridge) reference RK.sceneFromGameState / RK.sceneFromTavern; without these they were
// silently undefined (guarded → no-op). Expose them so those paths actually render.
// Convenience aliases on RK directly. matchClient, the tavern spectator and the SP render bridge
// reference RK.sceneFromGameState / RK.sceneFromTavern; without these they are undefined and
// those guarded call sites silently render nothing.
root.RK.sceneFromGameState = root.RK.sceneFromGameState || sceneFromGameState;
if (api.sceneFromTavern) root.RK.sceneFromTavern = root.RK.sceneFromTavern || api.sceneFromTavern;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
+2 -3
View File
@@ -1,6 +1,5 @@
// Character skins for the render kit. A skin is an animated sprite sheet (rows = facing
// directions, cols = animation frames) produced by the sprite pipeline
// (~/src/docs/animated-sprite-pipeline.md). Sheets are HEAVY and PREMIUM, so they are:
// Character skins for the render kit. A skin is an animated sprite sheet: rows are facing
// directions, columns are animation frames. Sheets are large and premium, so they are:
// - lazy-loaded (only fetched when an entity actually uses the skin, in a sprite-capable mode),
// - gated by entitlement (RK.entitlements.premium),
// - delivered as WebP.
+38 -39
View File
@@ -1,9 +1,9 @@
// SP game render bridge (RK.SPGame) — renders the single-player DUNGEON through the render kit
// (Tiled / ASCII / Iso / 3D + unlocked packs) instead of the legacy ROT display, so a pack you
// bought applies in actual gameplay, not just the tavern. It mounts a renderer in a dedicated host
// SP game render bridge (RK.SPGame): renders the single-player dungeon through the render kit
// (Tiled / ASCII / Iso / 3D + unlocked packs) rather than the legacy ROT display, so a purchased
// pack applies in gameplay and not only in the tavern. It mounts a renderer in a dedicated host
// inside #game-display and hides the legacy ROT canvases while a game is live; the retro splash /
// win / lose text screens still use ROT. If anything here is unavailable it returns false and the
// caller falls back to the legacy RenderEngine, so the live game can never hard-break.
// win / lose text screens still use ROT. When anything here is unavailable it returns false and the
// caller falls back to the legacy RenderEngine, so a live game can never hard-break.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
@@ -29,9 +29,8 @@
return doc().activeElement === gd;
};
// A dedicated, SELF-SIZING child host so the RK canvas doesn't collide with the ROT canvas and
// doesn't depend on the ROT canvas for its dimensions (it's an aspect-ratio viewport the camera
// transform is clipped to).
// A dedicated, self-sizing child host so the RK canvas neither collides with the ROT canvas nor
// depends on it for dimensions; it is the viewport the camera transform is clipped to.
function rkHost() {
var gd = gameDisplay();
if (!gd) return null;
@@ -39,12 +38,12 @@
if (!host) {
host = doc().createElement('div');
host.id = 'rk-game-host';
// FILL #game-display exactly (inset:0). #game-display is a definite, grid-bounded height
// (the .container is height:100vh; overflow:hidden), so filling it gives the camera
// reliable clientWidth/clientHeight every frame. The earlier `min(78vh)` + translate(-50%)
// centering could resolve to a wrong/transient size, stranding the view in a top band.
// Pure-black background so the area BEYOND the map edge is the SAME black as an
// unexplored ('dark') in-bounds tile — otherwise the shade difference outlines the map.
// inset:0 fills #game-display exactly. #game-display is a definite, grid-bounded height
// (.container is height:100vh; overflow:hidden), so filling it gives the camera reliable
// clientWidth/clientHeight every frame. Height caps plus translate(-50%) centering resolve
// to a wrong or transient size and strand the view in a top band, so avoid them.
// Pure-black background keeps the area beyond the map edge the same black as an
// unexplored ('dark') in-bounds tile; any shade difference outlines the map.
host.style.cssText = 'display:none; position:absolute; inset:0;' +
' overflow:hidden; background:#000; z-index:6; touch-action:none; cursor:grab;';
gd.appendChild(host);
@@ -131,8 +130,8 @@
if (!host || !RK.createRenderer) return null;
var mode = SP.mode();
// 3D needs THREE, lazy-loaded from the pinned same-origin vendor route (or the explicitly
// enabled CDN fallback). The sync createRenderer() returns Tiled until it is ready; remount
// as real 3D after loading so the screen never goes blank.
// enabled CDN fallback). The sync createRenderer() returns Tiled until it is ready, so the
// renderer is remounted as real 3D once loading finishes and the screen never goes blank.
if (mode === '3d' && RK.ensureThree && RK.threeReady && !RK.threeReady()) {
if (!SP._threePending) {
SP._threePending = true;
@@ -146,17 +145,17 @@
}
}
SP._renderer = RK.createRenderer(mode, host, { cell: 24 });
// The camera owns the canvas transform (centre on the player), so position it absolutely
// and let it overflow — do NOT use RK.attachZoom (it fights this transform).
// The camera owns the canvas transform (centre on the player), so the canvas is positioned
// absolutely and allowed to overflow. RK.attachZoom fights this transform; do not use it.
if (SP._renderer && SP._renderer.canvas) {
var c = SP._renderer.canvas;
c.style.position = 'absolute'; c.style.top = '0'; c.style.left = '0';
// Beat the `.rotdis canvas { max-width/height:100%; object-fit:contain }` rules (meant for
// the legacy ROT canvas) so the render-kit buffer maps 1:1 to the element and the CAMERA
// transform is the SOLE controller of scale/position. WITHOUT the max-height/object-fit
// override, a canvas TALLER than the host got object-fit-shrunk and the camera translate —
// computed in buffer space — pushed the player far off-screen (iso went fully BLACK; tiled's
// shorter canvas usually fit the host so it escaped the bug).
// Overrides the `.rotdis canvas { max-width/height:100%; object-fit:contain }` rules meant
// for the legacy ROT canvas, so the render-kit buffer maps 1:1 to the element and the
// camera transform is the sole controller of scale and position. Without these overrides a
// canvas taller than the host is object-fit-shrunk while the camera translate is computed
// in buffer space, pushing the player off-screen (iso canvases are tall enough to hit this;
// tiled canvases usually fit the host).
c.style.setProperty('max-width', 'none', 'important');
c.style.setProperty('max-height', 'none', 'important');
c.style.setProperty('object-fit', 'fill', 'important');
@@ -166,9 +165,9 @@
host._rkZoomBound = true;
host.addEventListener('wheel', function (ev) {
ev.preventDefault();
// Base the step on the CURRENT effective zoom (mode-aware default if not yet set),
// NOT a hardcoded 1.7 — otherwise the first scroll on iso (default 1.0) jumped up to
// 1.7 and BOTH directions read as "zoom in".
// The step is based on the current effective zoom (mode-aware default when unset).
// A hardcoded base makes the first scroll in a mode with a different default jump to
// that base, so both directions read as "zoom in".
var base = (SP._zoom != null) ? SP._zoom : SP._defaultZoom();
SP._setZoom(base * Math.exp(-ev.deltaY * 0.0015));
}, { passive: false });
@@ -208,14 +207,14 @@
return 1.7;
};
// Centre the (whole-scene) canvas on the player via a CSS transform, clipped to the host —
// renderer-agnostic (works for tiled / iso / 3d, each of which reports its own focusPoint).
// Centre the whole-scene canvas on the player via a CSS transform, clipped to the host. This is
// renderer-agnostic: tiled, iso and 3d each report their own focusPoint.
SP._applyCamera = function () {
var r = SP._renderer;
var host = doc() && doc().getElementById('rk-game-host');
if (!r || !r.canvas || !host) return;
// The 3D renderer fills the host itself (setSize) and its OWN THREE camera follows the
// player — so DON'T apply a CSS transform (it would double-transform the WebGL view).
// The 3D renderer fills the host itself (setSize) and its own THREE camera follows the
// player, so it takes no CSS transform; one would double-transform the WebGL view.
if (r.name === '3d') {
if (r.setZoom) r.setZoom((SP._zoom != null) ? SP._zoom : SP._defaultZoom());
r.canvas.style.transform = 'none';
@@ -225,10 +224,10 @@
}
var scale = (SP._zoom != null) ? SP._zoom : SP._defaultZoom();
var fp = r.focusPoint;
// ROBUSTNESS: if the renderer hasn't reported a focus this frame — a race at game start, or a
// camera tick reading a just-(re)created renderer instance before its first render — derive the
// focus straight from the last known player cell (tiled coords). Without this the camera falls
// back to a scale-only transform that pins the whole level to the top-left corner.
// A renderer can lack a focus for a frame: at game start, or when a camera tick reads a
// just-created renderer instance before its first render. In that case the focus is derived
// from the last known player cell (tiled coords); otherwise the camera falls back to a
// scale-only transform that pins the whole level to the top-left corner.
if (!fp && (r.name === 'tiles' || r.name === 'ascii') && SP._lastState && SP._lastState.player &&
typeof SP._lastState.player.x === 'number') {
var _c = r.cell || 24;
@@ -238,8 +237,8 @@
if (!fp) { r.canvas.style.transform = 'scale(' + scale + ')'; return; }
var w = host.clientWidth || host.offsetWidth || 640, h = host.clientHeight || host.offsetHeight || 400;
var tx = w / 2 - fp.x * scale, ty = h / 2 - fp.y * scale;
// Smooth follow: glide toward the target so single steps aren't jerky; SNAP on a big jump
// (level-change teleport / a stale-then-updated focus) rather than slowly panning the map.
// Smooth follow: glide toward the target so single steps aren't jerky, but snap on a big jump
// (level-change teleport, or a stale focus that then updates) rather than panning the map.
if (SP._camX == null || Math.abs(tx - SP._camX) > w || Math.abs(ty - SP._camY) > h) {
SP._camX = tx; SP._camY = ty;
} else {
@@ -272,8 +271,8 @@
return true;
};
// Re-apply the camera every frame while live — robust against the renderer resizing its canvas
// or a transient focusPoint, which was making the view snap to the corner mid-run.
// Re-applies the camera every frame while live, so a renderer resizing its canvas or a transient
// focusPoint cannot leave the view snapped to a corner mid-run.
SP._startCameraLoop = function () {
if (SP._camRaf != null) return;
function tick() {
+10 -10
View File
@@ -1,6 +1,6 @@
// TavernLife — client-side NPCs (bartender, patrons, cat) + speech bubbles that make the
// TavernLife: client-side NPCs (bartender, patrons, cat) + speech bubbles that make the
// tavern feel alive. NPCs are decorative: they wander, sit, and chat. They're client-side
// only (no server round-trips), so each client sees its own lively room — the real players
// only (no server round-trips), so each client sees its own lively room; the real players
// come from the server snapshot. Speech bubbles pop above any occupant (NPC or real player)
// when they speak.
(function (root) {
@@ -26,9 +26,9 @@
// ---- Speech lines -----------------------------------------------------------
var BARTENDER_LINES = [
"What'll it be, traveler?", "Ale's fresh today.", "Tab's full, friend.",
"Watch the monsters out there.", "Block's coming — hurry back.",
"Watch the monsters out there.", "Block's coming, hurry back.",
"I've seen braver than you not return.", "House special: liquid courage.",
"No credit — coin on the barrel.", "Heard someone found treasure last block.",
"No credit: coin on the barrel.", "Heard someone found treasure last block.",
"The dungeon's hungry tonight.", "Keep your wits about you.", "One ale, coming up."
];
var PATRON_LINES = [
@@ -40,7 +40,7 @@
"You new here?", "Don't go in unprepared.", "The exit was so close..."
];
var CAT_LINES = ["meow", "mrrrow", "prrrrp", "hisss", "...", "mew"];
var WELCOME_LINES = ["Welcome!", "Pull up a stool.", "New face — nice.", "Come in, sit down."];
var WELCOME_LINES = ["Welcome!", "Pull up a stool.", "New face, nice.", "Come in, sit down."];
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
@@ -80,7 +80,7 @@
return { minY: 3, maxY: rows - 2, minX: 1, maxX: cols - 2 };
}
function catArea(cols, rows) {
// The customer floor (in front of the bar) — not behind the counter, not the walls.
// The customer floor (in front of the bar): not behind the counter, not the walls.
return { minY: 4, maxY: rows - 2, minX: 1, maxX: cols - 2 };
}
@@ -223,7 +223,7 @@
}));
}
// A tavern cat — a random pixel cat from the Pet Cats Pack that wanders the floor.
// A tavern cat: a random pixel cat from the Pet Cats Pack that wanders the floor.
var catSpot = findFloor(4, rows - 4);
var cat = makeNPC('cat', 'cat', null, catSpot.x, catSpot.y, {
label: null, color: '#e8a060', role: 'cat', roamArea: catArea(cols, rows)
@@ -281,7 +281,7 @@
else if (stepY < 0) npc.facing = 'up';
else if (stepY > 0) npc.facing = 'down';
} else {
// Blocked — pick a new target.
// Blocked: pick a new target.
this._pickTarget(npc, scene);
}
// Cat moves faster; bartender slower.
@@ -350,7 +350,7 @@
y: originY + (e.x + e.y) * tileH / 2
};
} else if (mode === '3d') {
// 3D renderer uses Three.js — we can't easily get screen coords.
// 3D renderer uses Three.js; we can't easily get screen coords.
// Skip speech bubbles for 3D mode.
return null;
} else {
@@ -396,7 +396,7 @@
}
};
// Main update — called every frame via rAF.
// Main update: called every frame via rAF.
TavernLife.prototype.update = function (scene, cell, mode) {
if (!scene || !this._spawned) return;
var now = Date.now();
+35 -36
View File
@@ -1,5 +1,5 @@
// ThreeRenderer — a lit, textured 3D projection of the shared Scene. It uses generated GLB avatars
// when available and falls back to low-poly pieces so the mode remains usable during asset work.
// ThreeRenderer: a lit, textured 3D projection of the shared Scene. It uses GLB avatars when
// available and falls back to low-poly pieces so the mode stays usable without them.
//
// The level is drawn with three InstancedMeshes (walls / ground / props) so a 70x35 dungeon costs
// three draw calls instead of ~2500 individual meshes, and per-instance colour carries the same
@@ -16,8 +16,8 @@
return fallback || null;
}
// Deterministic PRNG — generated textures are byte-identical every session, so the dungeon
// doesn't subtly re-skin itself on reload.
// Deterministic PRNG: generated textures are byte-identical every session, so the dungeon
// does not subtly re-skin itself on reload.
function lcg(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
@@ -55,7 +55,7 @@
});
}
// Irregular flagstones — a broken grout grid plus grain, so a large floor never shows an
// Irregular flagstones: a broken grout grid plus grain, so a large floor never shows an
// obvious repeat.
function groundCanvas(size) {
return makeCanvas(size, function (c, s) {
@@ -115,7 +115,7 @@
};
var HAZARD_GLOW = { lava: 0.85, poison: 0.5, spikes: 0.12 };
// Ground tints. The scene legend colours are tuned for flat 2D fills and read almost black in a
// lit 3D scene, so wall/floor use material-appropriate values and everything else falls back to
// lit 3D scene, so wall/floor use material-appropriate values; everything else falls back to
// the legend.
var KIND_TINT = {
wall: '#a49a8b', window: '#7d94a8', door: '#9c7a4a', archway: '#a49a8b',
@@ -153,9 +153,9 @@
// Fog-of-war parity with the 2D modes: the camera follows the player, so distance fog fades
// explored tiles to black away from you, and _buildTiles skips unexplored ('dark') tiles so
// they never render. Together: unexplored = hidden, explored fades with distance, no map leak.
// Fog is measured from the CAMERA, which sits ~17 units back along the isometric offset —
// not from the player. A band tuned as if it started at the player put the player themselves
// a quarter of the way into fog and crushed the whole scene to near-black.
// The fog band is measured from the CAMERA, which sits ~17 units back along the isometric
// offset, not from the player. Tuning it as if it started at the player puts the player a
// quarter of the way into fog and crushes the scene to near-black.
this.scene.fog = new T.Fog(0x05060a, 21, 46);
this.camera = new T.OrthographicCamera(-10, 10, 10, -10, 0.1, 120);
this.camera.position.set(9, 11, 9);
@@ -180,7 +180,7 @@
var hemi = new T.HemisphereLight(0x9fb4d8, 0x161a24, 0.5);
this.scene.add(hemi);
this.keyLight = new T.DirectionalLight(0xffd9a8, 2.3);
this.keyLight.position.set(10, 8.5, 4); // low sun — long, readable wall shadows
this.keyLight.position.set(10, 8.5, 4); // low sun: long, readable wall shadows
this.keyLight.castShadow = true;
this.keyLight.shadow.mapSize.set(1024, 1024);
this.keyLight.shadow.camera.near = 1;
@@ -250,7 +250,7 @@
map: groundMap, bumpMap: groundBump, bumpScale: 0.35, roughness: 0.96, metalness: 0.0
});
this.propMat = new T.MeshStandardMaterial({ roughness: 0.8, metalness: 0.05 });
// Hazards/fire glow on their own so they stay legible in an unlit corridor.
// Hazards/fire glow on their own so they stay legible in an unlit corridor.
this.glowMat = new T.MeshStandardMaterial({
roughness: 0.55, metalness: 0.0, emissive: new T.Color(0xffffff), emissiveIntensity: 0.9
});
@@ -282,16 +282,16 @@
this.scene.add(this.motes);
};
// Fill the host and frame a fixed "screenful" around the player. The camera FOLLOWS the player
// Fill the host and frame a fixed "screenful" around the player. The camera follows the player
// (see _animate) instead of framing the whole level, so 3D behaves like the other modes. Only
// does work when the host size actually changed (cheap to call every frame).
// does work when the host size actually changed, so it is cheap to call every frame.
ThreeRenderer.prototype._fitToHost = function () {
var host = this.host;
var w = (host && (host.clientWidth || host.offsetWidth)) || 640;
var h = (host && (host.clientHeight || host.offsetHeight)) || 400;
if (w === this._cw && h === this._ch) return;
this._cw = w; this._ch = h;
this.renderer.setSize(w, h, true); // updateStyle:true → the canvas CSS-fills the host
this.renderer.setSize(w, h, true); // updateStyle:true so the canvas CSS-fills the host
var aspect = w / h, span = 6.6 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
this.camera.left = -span * aspect;
this.camera.right = span * aspect;
@@ -308,9 +308,9 @@
this._fitToHost();
};
// `attachCamera` reports drag pan in viewport pixels. Keep that contract for 3D and convert the
// `attachCamera` reports drag pan in viewport pixels. 3D keeps that contract and converts the
// offset to ground-plane world units in _animate(), where the followed player's current smoothed
// position is available. Bound it to one viewport so a stray gesture cannot lose the dungeon.
// position is available. The bound of one viewport keeps a stray gesture from losing the dungeon.
ThreeRenderer.prototype.setPan = function (x, y) {
var w = (this.host && (this.host.clientWidth || this.host.offsetWidth)) || 640;
var h = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
@@ -336,8 +336,8 @@
});
};
// One InstancedMesh per bucket, grown geometrically. Reusing the mesh across rebuilds is what
// keeps a fog-of-war update from re-allocating the whole level every time the player steps.
// One InstancedMesh per bucket, grown geometrically. Reusing the mesh across rebuilds keeps a
// fog-of-war update from re-allocating the whole level every time the player steps.
ThreeRenderer.prototype._bucket = function (name, geom, mat, needed) {
var T = this.THREE;
var b = this.buckets[name];
@@ -374,13 +374,13 @@
for (y = 0; y < scene.rows; y++) {
for (x = 0; x < scene.cols; x++) {
kind = scene.grid[y] && scene.grid[y][x];
if (!kind || kind === 'dark') continue; // unexplored — never render it (fog-of-war)
if (!kind || kind === 'dark') continue; // unexplored: never rendered (fog-of-war)
var def = legend[kind] || {};
var lb = (light && light[y] && light[y][x] != null) ? light[y][x] : 1;
// Unlike the 2D modes — where lightGrid IS the lighting — 3D has real point lights
// doing the falloff, so applying the grid at full strength to the albedo as well
// double-darkened every surface into near-black. Compress it into a gentle range and
// let the lamps shape the scene.
// In the 2D modes lightGrid IS the lighting; here real point lights do the falloff.
// Applying the grid at full strength to the albedo as well would double-darken every
// surface into near-black, so it is compressed into a gentle range and the lamps
// shape the scene.
var b = 0.38 + 0.62 * Math.max(0, Math.min(1, lb));
var tint = KIND_TINT[kind] || def.color || '#6d6558';
var cell = { x: x - cx, z: y - cz, gx: x, gy: y, b: b, tint: tint, kind: kind };
@@ -427,7 +427,7 @@
p.set(cell.x, h / 2, cell.z);
m.compose(p, q, s);
b.mesh.setMatrixAt(i, m);
// Hazards keep near-full brightness — a lava pool you can barely see is a trap, not a
// Hazards keep near-full brightness: a lava pool you can barely see is a trap, not a
// difficulty curve.
var hb = cell.hazard ? Math.max(0.7, cell.b) : cell.b;
c.set(colorNum(cell.tint)).multiplyScalar(hb);
@@ -438,7 +438,7 @@
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
// Walls are rewritten whenever the player's cell changes so the cutaway can follow them: walls
// Walls are rewritten whenever the player's cell changes so the cutaway follows them: walls
// sitting between the isometric camera (+x/+z) and the player are squashed and darkened, the 3D
// equivalent of the iso renderer's fade. Without it the near wall of every corridor hides you.
ThreeRenderer.prototype._writeWalls = function () {
@@ -467,9 +467,9 @@
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
};
// Braziers on the torch walls. Only the cells that newly appeared get geometry — the tile
// buckets are rewritten on every move (that is how remembered tiles keep dimming), and building
// a fresh mesh per torch per step would churn the GPU for no visual gain.
// Braziers on the torch walls. Only newly appeared cells get geometry: the tile buckets are
// rewritten on every move (that is how remembered tiles keep dimming), and building a fresh mesh
// per torch per step would churn the GPU for no visual gain.
ThreeRenderer.prototype._syncFixtures = function () {
var T = this.THREE;
this._flames = this._flames || {};
@@ -550,9 +550,8 @@
return g;
};
// Dungeon FEATURES/ITEMS (entrance/exit/treasure/pickups) are set dressing, not avatars. Before
// this branch existed every one of them was drawn as a humanoid capsule, so the entrance looked
// like a second player standing at the map origin.
// Dungeon features and items (entrance/exit/treasure/pickups) are set dressing, not avatars, so
// they get their own geometry rather than the humanoid capsule an avatar would draw.
ThreeRenderer.prototype._makeFeature = function (e) {
var T = this.THREE;
var g = new T.Group();
@@ -771,10 +770,10 @@
if (pcell) { this._plx = pcell.x; this._ply = pcell.y; }
// Rewrite the level every render. `render` is called on game updates (a keypress), not per
// frame, and the buckets are three reused InstancedMeshes — so this is a few thousand matrix
// frame, and the buckets are three reused InstancedMeshes, so this is a few thousand matrix
// writes, not an allocation. It has to be unconditional: `lightGrid` re-shades remembered
// tiles on EVERY step, so keying the rebuild on the grid alone froze the fog-of-war fade at
// whatever it was when a cell was first seen.
// tiles on every step, so keying the rebuild on the grid alone would freeze the fog-of-war
// fade at whatever it was when a cell was first seen.
this._buildTiles(scene);
var seen = {}, cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
@@ -866,7 +865,7 @@
}
this._playAction(o, moving ? 'run' : 'idle');
// Face the way you move. Prefer explicit facing (tavern); else infer from the world delta
// (the SP game doesn't send player.facing). world x=grid x, world z=grid y.
// (the SP game does not send player.facing). world x=grid x, world z=grid y.
var face = ent.e && ent.e.facing;
if (!face && moving) { face = Math.abs(dx) >= Math.abs(dz) ? (dx > 0 ? 'right' : 'left') : (dz > 0 ? 'down' : 'up'); ent._face = face; }
face = face || ent._face;
@@ -877,7 +876,7 @@
}
// Player-follow camera: keep the fixed iso offset but re-target the player's world position
// each frame. The player mesh itself lerps, so the camera glides smoothly with it.
this._fitToHost(); // pick up any host resize
this._fitToHost(); // picks up any host resize
var pcam = null;
for (var pid in this.entities) {
var pen = this.entities[pid];
-82
View File
@@ -1,82 +0,0 @@
// Tile-picker — a dev overlay to map a theme's tileset cells to game tile-kinds by CLICKING.
// Pick a kind (floor/wall/bar/table), click the matching cell in the sheet; it's saved to
// localStorage and the tavern re-renders live. Works for any pack, so no coordinate-guessing.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
RK.openTilePicker = function (onChange) {
var themeId = RK.activeThemeId;
var atlas = RK.themeAtlas(themeId);
if (!atlas) return;
atlas.onReady(function () { build(themeId, atlas, onChange); });
};
function build(themeId, atlas, onChange) {
var SCALE = 2;
var stride = atlas.tile + atlas.spacing;
var wrap = document.createElement('div');
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);z-index:9999;overflow:auto;padding:16px;font-family:ui-monospace,monospace;color:#d7dbe0;';
var bar = document.createElement('div');
bar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap;';
var active = RK.TILE_KINDS[0];
var status = document.createElement('span');
status.style.cssText = 'font-size:13px;color:#9cf;';
RK.TILE_KINDS.forEach(function (k) {
var b = document.createElement('button');
b.textContent = k; b.dataset.kind = k;
b.style.cssText = 'font:inherit;padding:6px 10px;border:1px solid #2a313a;border-radius:4px;background:#0a0c0f;color:#d7dbe0;cursor:pointer;';
b.onclick = function () { active = k; refresh(); status.textContent = 'Click the tile for: ' + k; };
bar.appendChild(b);
});
var done = document.createElement('button');
done.textContent = 'Done';
done.style.cssText = 'font:inherit;padding:6px 12px;border:0;border-radius:4px;background:#3fb950;color:#08130a;cursor:pointer;font-weight:600;margin-left:auto;';
done.onclick = function () { document.body.removeChild(wrap); };
bar.appendChild(status); bar.appendChild(done);
function refresh() {
Array.prototype.forEach.call(bar.querySelectorAll('button[data-kind]'), function (b) {
b.style.outline = b.dataset.kind === active ? '2px solid #3fb950' : 'none';
});
}
wrap.appendChild(bar);
var cv = document.createElement('canvas');
cv.width = atlas.img.width * SCALE; cv.height = atlas.img.height * SCALE;
cv.style.cssText = 'image-rendering:pixelated;border:1px solid #2a313a;background:#111;cursor:crosshair;';
var ctx = cv.getContext('2d');
function draw() {
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, cv.width, cv.height);
ctx.drawImage(atlas.img, 0, 0, cv.width, cv.height);
ctx.strokeStyle = 'rgba(63,185,80,0.22)'; ctx.lineWidth = 1;
for (var c = 0; c <= atlas.cols; c++) { ctx.beginPath(); ctx.moveTo(c * stride * SCALE, 0); ctx.lineTo(c * stride * SCALE, cv.height); ctx.stroke(); }
for (var r = 0; r <= atlas.rows; r++) { ctx.beginPath(); ctx.moveTo(0, r * stride * SCALE); ctx.lineTo(cv.width, r * stride * SCALE); ctx.stroke(); }
var map = RK.tileMap(themeId);
RK.TILE_KINDS.forEach(function (k) {
var co = map[k]; if (!co) return;
ctx.strokeStyle = '#ffb800'; ctx.lineWidth = 2;
ctx.strokeRect(co[0] * stride * SCALE, co[1] * stride * SCALE, atlas.tile * SCALE, atlas.tile * SCALE);
ctx.fillStyle = '#ffb800'; ctx.font = 'bold 11px monospace';
ctx.fillText(k[0].toUpperCase(), co[0] * stride * SCALE + 2, co[1] * stride * SCALE + 11);
});
}
cv.onclick = function (e) {
var rect = cv.getBoundingClientRect();
var col = Math.floor(((e.clientX - rect.left) / SCALE) / stride);
var row = Math.floor(((e.clientY - rect.top) / SCALE) / stride);
if (col < 0 || row < 0 || col >= atlas.cols || row >= atlas.rows) return;
RK.setTile(themeId, active, col, row);
draw();
if (onChange) onChange();
};
wrap.appendChild(cv);
document.body.appendChild(wrap);
refresh(); status.textContent = 'Click the tile for: ' + active; draw();
}
})(window);
+31 -38
View File
@@ -1,4 +1,4 @@
// TileRenderer — draws a Scene as tiled sprites + entity sprites on a 2D canvas.
// 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,
@@ -90,10 +90,9 @@
var tmap = (window.RK && RK.tileMap) ? RK.tileMap() : {};
var useAtlas = !!(atlas && atlas.ready);
// The atlas image loads async. If you switch INTO this renderer (e.g. entering the tavern
// in Tiled mode) before it's ready, the first paint uses the flat-colour fallback — which
// looks like plain blocks. Without a re-render it stays stuck there until the next scene
// update. Hook the atlas so we repaint the moment it's ready.
// The atlas image loads async. Switching into this renderer before it is ready paints the
// flat-colour fallback, and nothing repaints until the next scene update. Hooking onReady
// forces a repaint as soon as the atlas is available.
if (atlas && !atlas.ready && !this._atlasHooked && typeof atlas.onReady === 'function') {
this._atlasHooked = true;
var self = this;
@@ -101,7 +100,7 @@
}
if (!scene.isDungeon && window.RK && RK.roomReady && RK.roomReady()) {
// A designed room (imported .tmx) is loaded — draw its layered tiles.
// A designed room (imported .tmx) is loaded: draw its layered tiles.
RK.drawRoomCanvas(ctx, cell);
} else {
for (var y = 0; y < scene.rows; y++) {
@@ -109,9 +108,11 @@
var kind = scene.grid[y][x];
var px = x * cell, py = y * cell;
var def = scene.legend[kind] || { color: '#333' };
// Fire fixtures (torch/hearth) & hazard tiles (lava/poison/spikes) draw only
// their ground base here; RK.fx paints the animated flame / pulse over the
// snapshot in the flicker loop so they actually move. Skip the solid colour block.
// Fire fixtures (torch/hearth) and hazard tiles (lava/poison/spikes) draw only
// their ground base here (a wall for a dungeon torch); RK.fx paints the animated
// flame / pulse over the snapshot in the flicker loop so they move. The pack's
// torch SPRITE is deliberately skipped: several sheets place it off-center or
// rotated, so a procedural flame on a plain base reads correctly in every pack.
if (def.fx === 'fire' || def.hazard) {
var baseKind = def.over || 'floor';
var bco = useAtlas ? pickVariant(tmap[baseKind], x, y) : null;
@@ -121,20 +122,15 @@
ctx.fillStyle = bdef.color || def.color;
ctx.fillRect(px, py, cell, cell);
}
// Fire fixtures draw ONLY their base (wall for a dungeon torch) here; RK.fx
// paints the animated flame + warm glow centered on the cell in the flicker
// loop. We deliberately skip the pack's torch SPRITE — several sheets place it
// off-center / rotated, which read as misaligned; the procedural flame on a wall
// base is a clean wall-mounted torch that looks right in every pack.
applyCellLight(ctx, scene, x, y, px, py, cell);
continue;
}
var coord = useAtlas ? pickVariant(tmap[kind], x, y) : null;
if (coord) {
// Object tiles (furniture, windows, torches, treasure…) are transparent
// around the object. Legend `over` names the ground they sit on (floor/wall);
// draw that first so the transparency shows the room, not the dark canvas.
// Pipeline-level: any scene can declare it, not a tavern one-off.
// Object tiles (furniture, windows, torches, treasure) are transparent around
// the object. Legend `over` names the ground they sit on (floor/wall); drawing
// that first makes the transparency show the room, not the dark canvas. Any
// scene can declare `over`; it is not a tavern-only path.
if (def.over && tmap[def.over]) {
var base = pickVariant(tmap[def.over], x, y);
if (base) atlas.draw(ctx, base[0], base[1], px, py, cell);
@@ -177,7 +173,7 @@
var now = Date.now();
for (var i = 0; i < scene.entities.length; i++) {
var e = scene.entities[i];
// Dungeon features (entrance / exit / treasure) — real tiles, glyph fallback.
// Dungeon features (entrance / exit / treasure): real tiles, glyph fallback.
if (e.kind === 'feature') {
var fpx = e.x * cell, fpy = e.y * cell;
var fc = useAtlas ? pickVariant(featureCoord(tmap, e), e.x, e.y) : null;
@@ -200,7 +196,6 @@
}
continue;
}
// Dungeon items.
if (e.kind === 'item') {
var ipx = e.x * cell, ipy = e.y * cell;
var ic = useAtlas ? pickVariant(tmap.treasure, e.x, e.y) : null;
@@ -212,12 +207,10 @@
}
continue;
}
// Dungeon monster — draw the Goblin character sprite (same path the tavern draws
// characters through), falling back to a red orb only if the sprite is unavailable.
if (e.kind === 'monster') {
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.9, cell * 0.32, cell * 0.13);
// Prefer the active pack's monster TILE (e.g. the Original pack's ~ goblin) so a
// classic tile pack renders classic; else the character sprite; else a red orb.
// Preference order: the active pack's monster TILE, so a classic tile pack renders
// classic; then the Goblin character sprite; then a red orb.
var mCoord = useAtlas ? pickVariant(tmap.monster, e.x, e.y) : null;
if (mCoord) { atlas.draw(ctx, mCoord[0], mCoord[1], e.x * cell, e.y * cell, cell); continue; }
var mAvatar = e.avatar || 'char-goblin';
@@ -236,15 +229,15 @@
ctx.stroke();
continue;
}
// Tavern cat — a standalone animated sprite (Pet Cats Pack), not a char-atlas avatar.
// Tavern cat: a standalone animated sprite (Pet Cats Pack), not a char-atlas avatar.
if (e.kind === 'cat') {
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.9, cell * 0.28, cell * 0.11);
if (window.RK && RK.drawCatCanvas && RK.drawCatCanvas(ctx, e, cell, now)) 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);
// In the DUNGEON, prefer the active pack's player TILE (Original pack's @ hero) over the
// sprite so a classic pack is fully classic. The tavern keeps character sprites.
// In the dungeon the active pack's player TILE wins over the character sprite so a
// classic pack is fully classic. The tavern keeps character sprites.
if (scene.isDungeon && (e.you || e.kind === 'player') && useAtlas) {
var pCoord = pickVariant(tmap.player, e.x, e.y);
if (pCoord) { atlas.draw(ctx, pCoord[0], pCoord[1], e.x * cell, e.y * cell, cell); continue; }
@@ -293,9 +286,9 @@
// 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.
// Where the player is on this canvas — the SP camera centers on it. Sticky: keep the last
// point if the player is momentarily absent (level transition), so the view never snaps to
// the corner.
// focusPoint is where the player is on this canvas; the SP camera centers on it. It is
// sticky: while the player is momentarily absent (level transition) the last point stands,
// so the view never snaps to the corner.
var _fp = findPlayer(scene);
if (_fp) this.focusPoint = { x: (_fp.x + 0.5) * cell, y: (_fp.y + 0.5) * cell };
@@ -375,8 +368,8 @@
};
TileRenderer.prototype._ensureEmbers = function (scene) {
// Embers belong to the dungeon torch the player carries — never in the tavern (or any
// non-dungeon scene), where they'd make the "you" avatar look like a flaming dot.
// Embers belong to the dungeon torch the player carries, never to the tavern or any other
// non-dungeon scene, where they would make the "you" avatar look like a flaming dot.
if (!scene || !scene.isDungeon) { this._embers = []; return; }
var p = findPlayer(scene);
if (!p) { this._embers = []; return; }
@@ -414,14 +407,15 @@
}
};
// 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).
// Warm torch light on the player cell + drifting embers + an edge vignette, on 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;
// The warm torch light + embers are a DUNGEON effect (the player carries the torch). In the
// tavern there's no torch, so skip it — otherwise the "you" cell glows like a flaming dot.
// The warm torch light + embers are a dungeon effect (the player carries the torch). The
// tavern has no torch, so it is skipped there; otherwise the "you" cell glows like a
// flaming dot.
var p = (scene && scene.isDungeon) ? findPlayer(scene) : null;
if (p) {
var cx = p.x * cell + cell / 2, cy = p.y * cell + cell / 2;
@@ -437,7 +431,6 @@
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;
@@ -463,7 +456,7 @@
ctx.fillRect(0, 0, w, h);
ctx.restore();
// Animated fire fixtures + hazard tiles — drawn over the vignette so their light punches
// Animated fire fixtures + hazard tiles draw over the vignette so their light punches
// through the dark edges rather than being dimmed by it.
var em = this._emitters;
if (em && em.length && root.RK && RK.fx) {
+2 -2
View File
@@ -5,11 +5,11 @@
// signed by this session's authenticated npub, then relays it to nostr under that npub.
//
// Returns a Promise<boolean>: true if it signed + emitted, false if Smirk signing is
// unavailable or declined — in which case the caller falls back to the normal unsigned send.
// unavailable or declined, in which case the caller falls back to the normal unsigned send.
// Feature-detected end to end, so it's a no-op (always false) until the page has a connected
// Smirk wallet; wiring it into a send site is therefore always safe.
// Sign an event, granting the Nostr scope on demand. Signing FIRST means a returning user
// (scope already granted for this origin — e.g. from the game's NIP-98 login) gets a single
// (scope already granted for this origin, e.g. from the game's NIP-98 login) gets a single
// approval; only a first-time origin hits NOT_AUTHORIZED and needs the one-time scope grant.
// Same pattern as SmirkAuth (html/js/network/smirkAuth.js).
function signWithScope(smirk, tmpl) {
+5 -5
View File
@@ -66,7 +66,7 @@ const HelpModal = {
$container.html(`
<div style="padding:8px 10px; background:#0d3320; border:1px solid #4ade80; border-radius:6px;">
<strong style="color:#4ade80;">🆓 Free to play</strong><br>
<span>No payment needed — just escape the dungeon.</span>
<span>No payment needed: just escape the dungeon.</span>
</div>
`);
return;
@@ -86,13 +86,13 @@ const HelpModal = {
const payoutsOn = !!(config.directPayoutsEnabled || config.creditsPayoutsEnabled);
const buy = [];
if (directOn) buy.push(`<strong>1 credit</strong> — ${price} ${currency}`);
if (directOn) buy.push(`<strong>1 credit</strong>: ${price} ${currency}`);
if (creditsOn && packages.length > 0) {
const bundles = packages.map(pkg => {
const bonus = pkg.bonus > 0 ? ` +${pkg.bonus}` : '';
return `${pkg.credits}${bonus} for ${pkg.priceFormatted}`;
}).join(' · ');
buy.push(`<strong>Bundles</strong> — ${bundles} ${currency}`);
buy.push(`<strong>Bundles</strong>: ${bundles} ${currency}`);
}
// Reward line is config-driven so it stays honest on prestige (no-payout) deployments.
@@ -100,7 +100,7 @@ const HelpModal = {
if (payoutsOn) {
reward = '<span style="color:#4ade80;">💰 Exact escape and treasure rewards are shown before entry and locked for that run.</span>';
} else {
reward = `<span style="color:#fbbf24;">🏅 No crypto payout — escape for the win; top scores enter the <strong>Hall of Champions</strong>.</span>`;
reward = `<span style="color:#fbbf24;">🏅 No crypto payout: escape for the win; top scores enter the <strong>Hall of Champions</strong>.</span>`;
}
const freeLine = config.freePlayEnabled
@@ -115,7 +115,7 @@ const HelpModal = {
${buy.map(b => '&nbsp;&nbsp;• ' + b).join('<br>')}
</div>
<div style="font-size:0.82em; color:#9aa4b2; margin-bottom:6px;">
Spend a credit to enter — jump in immediately, or on the next block. Block timing
Spend a credit to enter: jump in immediately, or on the next block. Block timing
is random, so your average wait is the same either way.
</div>
<div style="font-size:0.85em;">${reward}</div>
+4 -4
View File
@@ -20,7 +20,7 @@ const PaymentUI = {
$('#payment-ui').on('click', '.mode-option', function() {
// The "Buy Selected Package" button ALSO carries .mode-option (for styling) but has its
// own handler below; without this guard it double-fired here with NO data attrs, defaulting
// to single_game — so buying a 10-credit pack ALSO created a spurious 1-credit invoice.
// to single_game, so buying a 10-credit pack ALSO created a spurious 1-credit invoice.
if (this.id === 'buy-credits-btn') return;
const mode = $(this).data('mode');
const action = $(this).data('action');
@@ -163,7 +163,7 @@ const PaymentUI = {
render: function() {
if (!this.config) return;
// Display label (sXMR on stagenet) — distinct from cryptoType, which formatPrice
// Display label (sXMR on stagenet): distinct from cryptoType, which formatPrice
// still uses for decimals.
const currency = this.config.currencyLabel || this.config.cryptoType || 'WOW';
const hasCredits = this.userCredits >= (this.config.creditsPerGame || 1);
@@ -185,7 +185,7 @@ const PaymentUI = {
$container.append(
'<div style="margin-bottom:14px;padding:10px;background:rgba(245,158,11,0.08);border:1px solid #92400e;border-radius:6px;font-size:0.85em;line-height:1.5;">' +
'🏅 <strong style="color:#fbbf24;">Pay</strong> (' + this.escapeHtml(paidEntryCopy) + ') to put your high scores in the <strong style="color:#fbbf24;">Hall of Champions</strong>.<br>' +
'🪙 <strong style="color:#8ab4f8;">Play free</strong> — high scores go to the <strong style="color:#8ab4f8;">Pleb leaderboard</strong> instead.' +
'🪙 <strong style="color:#8ab4f8;">Play free</strong>: high scores go to the <strong style="color:#8ab4f8;">Pleb leaderboard</strong> instead.' +
'</div>'
);
}
@@ -366,7 +366,7 @@ const PaymentUI = {
// alongside paid (freePlayEnabled). Free games go to the Pleb leaderboard.
if (this.config.freePlayEnabled) {
const note = this.config.paymentsEnabled
? '🪙 Free — high scores go to the Pleb leaderboard'
? '🪙 Free: high scores go to the Pleb leaderboard'
: 'No payments required';
$container.append(`
<button class="mode-option" data-mode="FREE" data-action="free"
+2 -2
View File
@@ -1,8 +1,8 @@
/**
* Public stats strip — light "social proof" shown in the status area: games and escapes in
* Public stats strip: light "social proof" shown in the status area: games and escapes in
* the last 24h, and total paid out (only where payouts are enabled). Polls /api/stats, which
* is cached server-side (10s), so a 30s poll is cheap. Online-player count is intentionally
* NOT shown here — it already updates live via the 'user_count' socket event.
* NOT shown here; it already updates live via the 'user_count' socket event.
*/
(function () {
var EL_ID = 'publicStats';
+4 -4
View File
@@ -1,4 +1,4 @@
// Homepage render picker — render mode (Tiled / ASCII / Iso / 3D) + tile-pack selector for the
// Homepage render picker: render mode (Tiled / ASCII / Iso / 3D) + tile-pack selector for the
// single-player dungeon, gated by entitlements. Free players see Tiled(Original) + ASCII; the credit
// ladder unlocks the richer packs, iso, and 3D. Rebuilds itself whenever entitlements change.
(function (root) {
@@ -54,8 +54,8 @@
b.className = 'rp-btn' + (m.id === current ? ' active' : '') + (usable ? '' : ' locked');
b.textContent = m.label + (!availability.runtimeAvailable ? ' ⛔' : (m.premium && !usable ? ' 🔒' : ''));
b.title = !availability.runtimeAvailable
? (m.label + ' — unavailable on this server (optional renderer dependency disabled)')
: (usable ? m.label : (m.label + ' — unlock with credits'));
? (m.label + ': unavailable on this server (optional renderer dependency disabled)')
: (usable ? m.label : (m.label + ': unlock with credits'));
b.setAttribute('aria-disabled', usable ? 'false' : 'true');
b.onclick = function () {
if (!usable) return;
@@ -68,7 +68,7 @@
});
h.appendChild(modeRow);
// Pack row — only when the current projection has more than one UNLOCKED pack to pick from.
// Pack row: only when the current projection has more than one UNLOCKED pack to pick from.
var proj = projForMode(current);
var packs = (proj && RK.unlockedPacks) ? RK.unlockedPacks(proj) : [];
if (packs.length > 1) {
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wownerogue — Multiplayer Arena</title>
<title>Wownerogue: Multiplayer Arena</title>
<link rel="stylesheet" href="styles/commerce-consent.css">
<style>
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator.ttf') format('truetype'); font-display: swap; }
+12 -12
View File
@@ -14,7 +14,7 @@
}
/* =========================================================================
DESIGN TOKENS — "dungeon console" palette
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.
========================================================================= */
@@ -76,7 +76,7 @@ body {
width: 100%;
max-width: 100vw;
box-sizing: border-box;
/* A few px of right gutter so the play area isn't flush to the viewport edge — otherwise
/* A few px of right gutter so the play area isn't flush to the viewport edge; otherwise
overflow:hidden clips the yellow focus outline on the right side. */
padding-right: 6px;
overflow: hidden;
@@ -99,7 +99,7 @@ body {
letter-spacing: 1px;
}
/* Status area for block height display — the "control console" column */
/* Status area for block height display: the "control console" column */
.status {
grid-area: status;
position: relative;
@@ -114,7 +114,7 @@ body {
justify-content: flex-start;
border-right: 1px solid var(--rule);
box-shadow: inset 0 0 32px rgba(0, 0, 0, 0.45);
overflow-y: auto; /* it now only spans the game row — scroll if the controls are tall */
overflow-y: auto; /* it now only spans the game row, so it scrolls if the controls are tall */
min-height: 0;
}
@@ -140,7 +140,7 @@ body {
}
/* =========================================================================
CONTROL BAR — primary action buttons (#startButton, #watchGamesButton,
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).
@@ -185,7 +185,7 @@ body {
outline-offset: 2px;
}
/* Variants — colours preserved from the original inline styles. */
/* 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); }
@@ -197,7 +197,7 @@ body {
.ctrl-btn--start { animation: torch-glow 3.4s ease-in-out infinite; }
.ctrl-btn--start:hover { animation: none; }
/* Sidebar group headers (Loadout / Explore / Progress) — the redesigned menu grouping. */
/* Sidebar group headers (Loadout / Explore / Progress): the redesigned menu grouping. */
.menu-grp-h {
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
color: #7a8a5f; font-weight: 700; margin: 12px 0 5px; padding: 9px 0 0 2px;
@@ -205,7 +205,7 @@ body {
}
/* =========================================================================
HUD TOOLBAR — secondary "chip" controls (audio toggle + help / history /
HUD TOOLBAR: secondary "chip" controls (audio toggle + help / history /
scores / manage-address). Small, bordered, glow-on-hover.
========================================================================= */
.hud-toolbar {
@@ -687,7 +687,7 @@ li.debug {
.win-toast strong { color: var(--good); }
/* =========================================================================
Motion — tasteful transitions & keyframes (all respect reduced-motion)
Motion: tasteful transitions & keyframes (all respect reduced-motion)
========================================================================= */
@keyframes torch-flicker {
0%, 100% { opacity: 0.8; }
@@ -743,7 +743,7 @@ li.debug {
/* Stack the desktop 3-column grid into a single column.
Rows: header / status / game / CHAT(flex) / input / footer.
The game row is `auto` (sized by the canvas's own aspect ratio at full width)
and the CHAT row takes the leftover space (1fr) instead — so the play area fills
and the CHAT row takes the leftover space (1fr) instead: so the play area fills
the screen width and chat gets whatever height remains. */
.container {
grid-template-areas:
@@ -807,7 +807,7 @@ li.debug {
/* 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 */
/* Fixed-width modals would overflow tiny screens: make them fit */
#payment-ui, #history-panel, #leaderboard-panel {
min-width: 0 !important;
width: 94vw !important;
@@ -819,7 +819,7 @@ li.debug {
}
/* On-screen D-pad — styling defined unconditionally; shown on small screens (media query
/* On-screen D-pad: styling defined unconditionally; shown on small screens (media query
above) OR any touch device (body.touch set by touchControls.js for landscape/tablets). */
@media (max-width: 820px) { #touch-dpad { display: grid; } }
body.touch #touch-dpad { display: grid; }
+6 -6
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Wownerogue — Tavern</title>
<title>Wownerogue: Tavern</title>
<link rel="stylesheet" href="styles/commerce-consent.css">
<script src="/socket.io/socket.io.js"></script>
<script src="js/legal/commerceConsent.js"></script>
@@ -220,7 +220,7 @@
var spectatorDpad = document.getElementById('spectatorDpad');
var leaveSpectateBtn = document.getElementById('leaveSpectateBtn');
// Character customization — persisted appearance identity, chosen before entering.
// Character customization: persisted appearance identity, chosen before entering.
var customizeBtn = document.getElementById('customizeBtn');
var appearancePreview = document.getElementById('appearancePreview');
var selectedAppearance = ((RK.loadIdentity && RK.loadIdentity()) || {}).appearance || RK.normalizeAppearance('default');
@@ -375,7 +375,7 @@
var active = activeMode;
setStatus(active === mode
? 'Render mode: ' + active
: '“' + mode + '” unavailable here — using “' + active + '”.');
: '“' + mode + '” unavailable here, using “' + active + '”.');
});
}
function buildModebar() {
@@ -398,7 +398,7 @@
function projForMode(mode) {
if (mode === 'iso') return 'iso';
if (mode === '3d') return '3d';
if (mode === 'ascii') return null; // ASCII draws glyphs — it consumes no tile pack
if (mode === 'ascii') return null; // ASCII draws glyphs; it consumes no tile pack
return 'topdown';
}
@@ -555,7 +555,7 @@
//
// This used to key off `m.socketId`, which the chat provider deliberately stopped
// sending (it publishes a short `publicId` instead so the raw socket id never leaves the
// server) — so the bubble branch simply never ran and nobody in the tavern ever spoke
// server), so the bubble branch simply never ran and nobody in the tavern ever spoke
// visibly. Resolve the speaker from the occupant list by display name instead, which
// needs no additional identity on the wire.
if (tavernLife) {
@@ -576,7 +576,7 @@
socket.on('tavern_notice', function (n) {
if (n && n.message) addSystemLine(n.message);
});
// Global chat backlog sent on join — render it so the tavern shows history, not a blank log.
// Global chat backlog sent on join: render it so the tavern shows history, not a blank log.
socket.on('chat_history', function (payload) {
var msgs = payload && payload.messages;
if (!msgs || !msgs.length) return;