mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 14:51:26 +02:00
A browser-based roguelike synchronized with crypto block timing. A run is a race against the next block: reach the exit before the block lands, or the house wins. The same codebase runs the solo dungeon, the social Tavern, and operator-selected PvP and race rulesets, with separate free and paid leaderboards. Self-hostable under the MIT License. See README.md to run it, docs/ARCHITECTURE.md for the design, and docs/DEPLOY.md to operate an instance.
575 lines
22 KiB
JavaScript
575 lines
22 KiB
JavaScript
// 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) {}
|
|
};
|
|
}
|
|
|
|
var Game = {
|
|
_screenWidth: 25, // Overridden from options when available
|
|
_screenHeight: 19,
|
|
_inputEnabled: true,
|
|
_messageLog: [],
|
|
_maxLogMessages: 5,
|
|
_unconfirmedPayment: false,
|
|
_unconfirmedPaymentInfo: null,
|
|
_awaitingPayment: false,
|
|
_isSpectating: false, // True when viewing someone else's game
|
|
_gameActive: false, // True when game is running (playing or spectating)
|
|
|
|
init: function() {
|
|
if (typeof options !== 'undefined') {
|
|
this._screenWidth = options.width || 25;
|
|
this._screenHeight = options.height || 19;
|
|
}
|
|
|
|
if (!DisplayManager.init(this._screenWidth, this._screenHeight)) {
|
|
return false;
|
|
}
|
|
|
|
ScreenManager.init(this._screenWidth, this._screenHeight);
|
|
RenderEngine.init(this._screenWidth, this._screenHeight);
|
|
GameState.init();
|
|
|
|
ScreenManager.drawWelcomeScreen();
|
|
|
|
return true;
|
|
},
|
|
|
|
_ensureDisplay: function() {
|
|
return DisplayManager.ensureDisplay();
|
|
},
|
|
|
|
_isFirefox: function() {
|
|
return navigator.userAgent.toLowerCase().indexOf('firefox') > -1;
|
|
},
|
|
|
|
startGame: function(playerData, mapData, monsterData, itemData, initialVisibleTiles, lightingData, torchData) {
|
|
if (!this._ensureDisplay()) {
|
|
console.error("Game: Cannot start game, display not ready.");
|
|
return false;
|
|
}
|
|
|
|
DisplayManager.clearDisplay();
|
|
|
|
// Drop cached display glyphs so no welcome-screen artifacts survive into the level.
|
|
if (typeof DisplayManager !== 'undefined' && DisplayManager.forceClearToBlack) {
|
|
DisplayManager.forceClearToBlack();
|
|
}
|
|
|
|
// The welcome screen's block simulation draws on the same display and must be stopped first.
|
|
if (typeof ScreenManager !== 'undefined' && ScreenManager.stopBlockSimulation) {
|
|
ScreenManager.stopBlockSimulation();
|
|
}
|
|
|
|
try {
|
|
GameState.setGameActive(true);
|
|
|
|
// Clear previous game state. _visibleTiles is left alone: initializeMap seeds it from
|
|
// the server's initial visible set.
|
|
GameState._exploredTiles = {};
|
|
GameState._map = {};
|
|
GameState._items = {};
|
|
GameState._monster = null;
|
|
GameState._entrance = null;
|
|
GameState._exit = null;
|
|
GameState._treasure = null;
|
|
|
|
GameState.initializePlayer(playerData);
|
|
|
|
GameState._monster = monsterData;
|
|
GameState._items = itemData;
|
|
GameState._entrance = null;
|
|
GameState._exit = null;
|
|
GameState._treasure = null;
|
|
|
|
if (lightingData) {
|
|
GameState._lighting = lightingData;
|
|
}
|
|
if (torchData) {
|
|
GameState._torches = torchData;
|
|
}
|
|
|
|
GameState.initializeMap(mapData, initialVisibleTiles, this._screenWidth, this._screenHeight);
|
|
|
|
if (!GameState.validatePlayerPosition(this._screenWidth, this._screenHeight)) {
|
|
ScreenManager.drawCenteredText(10, "Error: Map invalid / No player start!");
|
|
return false;
|
|
}
|
|
|
|
GameState.computeFieldOfView();
|
|
|
|
if (!GameState._scheduler) GameState._scheduler = new ROT.Scheduler.Simple();
|
|
else GameState._scheduler.clear();
|
|
if (!GameState._engine) GameState._engine = new ROT.Engine(GameState._scheduler);
|
|
|
|
GameState._scheduler.add(GameState._player, true);
|
|
|
|
if (!GameState._map[GameState._player.y] || GameState._map[GameState._player.y][GameState._player.x] === undefined) {
|
|
console.error(`Player position (${GameState._player.x}, ${GameState._player.y}) is STILL invalid before drawing. Map issue?`);
|
|
ScreenManager.drawCenteredText(10, "Error: Player invalid for draw!");
|
|
return false;
|
|
}
|
|
|
|
this._drawGameScreen();
|
|
|
|
// Starts the FX overlay (ambient embers, flicker, particle loop). No-op without window.FX.
|
|
this._fxStart();
|
|
|
|
return true;
|
|
|
|
} catch (err) {
|
|
console.error("Error during Game.startGame:", err);
|
|
console.error("Error name:", err.name, "Error message:", err.message, "Stack:", err.stack);
|
|
if (err.message && (err.message.includes("_map") || err.message.includes("Cannot read properties of undefined"))) {
|
|
console.error("Detailed error info: Player:", GameState._player, "Map keys:", Object.keys(GameState._map || {}).length);
|
|
}
|
|
try {
|
|
ScreenManager.drawCenteredText(10, "Error starting game!");
|
|
ScreenManager.drawCenteredText(12, "Check console (F12) for details.");
|
|
} catch (drawError) {
|
|
console.error("Failed to draw error message on screen:", drawError);
|
|
}
|
|
return false;
|
|
}
|
|
},
|
|
|
|
// Apply a state update pushed by the server.
|
|
updateGameState: function(data) {
|
|
// 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);
|
|
|
|
if (needsRedraw) {
|
|
this._drawGameScreen();
|
|
}
|
|
|
|
if (window.FX && needsRedraw) {
|
|
try {
|
|
const newTreasureCount = this._fxTreasureCells().length;
|
|
if (newTreasureCount < prevTreasureCount) {
|
|
// An item vanished: gold burst for the pickup.
|
|
const pp = this._playerScreenPixel();
|
|
window.FX.burst(pp.x, pp.y, '#ffd700', 28);
|
|
} else {
|
|
// Proximity effects for server-authoritative moves.
|
|
this._fxOnPlayerMoved();
|
|
}
|
|
} catch (e) { /* FX is best-effort; never break gameplay */ }
|
|
}
|
|
},
|
|
|
|
// Alias kept for handlers that call updateGame.
|
|
updateGame: function(data) {
|
|
return this.updateGameState(data);
|
|
},
|
|
|
|
// Handle the game-over event from the server.
|
|
endGame: function(data) {
|
|
try {
|
|
GameState.setGameActive(false);
|
|
if (data && data.reason === 'monster') {
|
|
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('monster');
|
|
} else if (data && data.reason === 'escaped') {
|
|
if (ScreenManager.drawWinScreen) ScreenManager.drawWinScreen(data.treasure || false);
|
|
} else if (data && data.reason === 'timeout') {
|
|
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('timeout');
|
|
} else {
|
|
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('other');
|
|
}
|
|
|
|
// 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) {
|
|
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 {
|
|
// Monster, timeout or other loss.
|
|
window.FX.shake(12, 500);
|
|
window.FX.flash('rgba(255,0,0,1)', 0.4, 450);
|
|
}
|
|
// Gameplay is over, so quiet the ambient torch embers.
|
|
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
|
} catch (e) { /* best-effort */ }
|
|
}
|
|
|
|
const score = data && typeof data.score === 'number' ? data.score : 0;
|
|
const treasure = !!(data && data.treasure);
|
|
|
|
if (ScreenManager && ScreenManager.drawCenteredText && DisplayManager.ensureDisplay()) {
|
|
const baseY = Math.floor(ScreenManager._screenHeight / 2) + 5;
|
|
ScreenManager.drawCenteredText(baseY, `Score: ${score}`);
|
|
if (treasure) {
|
|
ScreenManager.drawCenteredText(baseY + 1, 'Treasure secured!');
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
const autoReturnDelay = 10000;
|
|
|
|
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; // Leaves a one-cell margin each side
|
|
const hint = fullHint.length > maxLen ? shortHint : fullHint;
|
|
ScreenManager.drawCenteredText(y, hint);
|
|
}
|
|
}, activateDelay);
|
|
|
|
clearTimeout(this._autoReturnTimer);
|
|
this._autoReturnTimer = setTimeout(() => {
|
|
if (this._awaitingRestart) {
|
|
this._awaitingRestart = false;
|
|
this._fxStop(); // Tears down the FX loop so requestAnimationFrame does not leak
|
|
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
|
ScreenManager.drawWelcomeScreen();
|
|
}
|
|
}
|
|
}, autoReturnDelay);
|
|
|
|
// 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) => {
|
|
if (!this._awaitingRestart) return;
|
|
if (e.key === 'Enter') {
|
|
this._awaitingRestart = false;
|
|
clearTimeout(this._autoReturnTimer);
|
|
this._fxStop(); // Tears down the FX loop so requestAnimationFrame does not leak
|
|
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
|
ScreenManager.drawWelcomeScreen();
|
|
}
|
|
const chatInput = document.getElementById('chatInput');
|
|
if (chatInput) chatInput.focus();
|
|
}
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error('Error handling endGame:', err);
|
|
}
|
|
},
|
|
|
|
clearDisplay: function() {
|
|
DisplayManager.clearDisplay();
|
|
},
|
|
|
|
_drawGameScreen: function() {
|
|
const gameState = GameState.getGameStateForRender();
|
|
// 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();
|
|
rk = RK.SPGame.render(gameState, {
|
|
cryptoType: (window.ScreenManager && ScreenManager._cryptoType) || undefined,
|
|
playerAppearance: (window.SinglePlayerAvatar && SinglePlayerAvatar.currentAppearance)
|
|
? SinglePlayerAvatar.currentAppearance() : null,
|
|
isSpectating: this._isSpectating
|
|
});
|
|
}
|
|
if (!rk) {
|
|
if (window.RK && RK.SPGame) RK.SPGame.hide();
|
|
if (!this._ensureDisplay()) return;
|
|
RenderEngine.drawGameScreen(gameState);
|
|
if (window.FX) {
|
|
try { this._fxSyncLighting(); } catch (e) { /* best-effort */ }
|
|
}
|
|
}
|
|
},
|
|
|
|
// Screen drawing delegates to ScreenManager.
|
|
_drawWelcomeScreen: function() {
|
|
ScreenManager.drawWelcomeScreen();
|
|
},
|
|
|
|
drawWinScreen: function(hasTreasure) {
|
|
ScreenManager.drawWinScreen(hasTreasure);
|
|
},
|
|
|
|
drawLoseScreen: function(reason) {
|
|
ScreenManager.drawLoseScreen(reason);
|
|
},
|
|
|
|
drawWaitingScreen: function() {
|
|
ScreenManager.drawWaitingScreen();
|
|
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; // 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');
|
|
}
|
|
const pulse = 0.4 + Math.sin(Date.now()/300)*0.4;
|
|
display.draw(xStart - 2, y, '*', `rgba(120,200,255,${pulse})`, 'transparent');
|
|
}
|
|
},
|
|
|
|
stopWaitingScreen: function() {
|
|
this._awaitingPayment = false;
|
|
this._unconfirmedPayment = false;
|
|
ScreenManager.stopWaitingScreen();
|
|
},
|
|
|
|
// Display mode switching delegates to DisplayManager.
|
|
switchToAsciiMode: function() {
|
|
DisplayManager.switchToAsciiMode();
|
|
},
|
|
|
|
switchToTileMode: function() {
|
|
DisplayManager.switchToTileMode();
|
|
},
|
|
|
|
// Player movement delegates to GameState.
|
|
movePlayer: function(dx, dy) {
|
|
const moved = GameState.movePlayer(dx, dy, this._screenWidth, this._screenHeight);
|
|
if (moved) {
|
|
this._drawGameScreen();
|
|
if (window.FX) {
|
|
try { this._fxOnPlayerMoved(); } catch (e) { /* best-effort */ }
|
|
}
|
|
}
|
|
},
|
|
|
|
// ------------------------------------------------------------------
|
|
// 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 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 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);
|
|
var centerY = Math.floor(this._screenHeight / 2);
|
|
var player = (GameState && GameState._player) ? GameState._player : { x: wx, y: wy };
|
|
var sx = wx - player.x + centerX;
|
|
var sy = wy - player.y + centerY;
|
|
return { x: sx * cell + cell / 2, y: sy * cell + cell / 2, cell: cell };
|
|
},
|
|
|
|
// The player is camera-centered, so this is always the center of the view.
|
|
_playerScreenPixel: function() {
|
|
var p = (GameState && GameState._player) ? GameState._player : { x: 0, y: 0 };
|
|
return this._cellToScreenPixel(p.x, p.y);
|
|
},
|
|
|
|
// Chebyshev (king-move) distance, matching movement on the grid.
|
|
_fxDist: function(ax, ay, bx, by) {
|
|
return Math.max(Math.abs(ax - bx), Math.abs(ay - by));
|
|
},
|
|
|
|
// Collect every treasure/item world cell currently known.
|
|
_fxTreasureCells: function() {
|
|
var cells = [];
|
|
if (typeof GameState === 'undefined') return cells;
|
|
var t = GameState._treasure;
|
|
if (t && typeof t[0] === 'number' && typeof t[1] === 'number') {
|
|
cells.push({ x: t[0], y: t[1] });
|
|
}
|
|
var items = GameState._items;
|
|
if (items) {
|
|
for (var k in items) {
|
|
if (items.hasOwnProperty(k)) {
|
|
var it = items[k];
|
|
if (it && typeof it.x === 'number' && typeof it.y === 'number') {
|
|
cells.push({ x: it.x, y: it.y });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return cells;
|
|
},
|
|
|
|
// Fire proximity/pickup FX for the player's current position.
|
|
_fxOnPlayerMoved: function() {
|
|
if (!window.FX) return;
|
|
var player = (GameState && GameState._player) ? GameState._player : null;
|
|
if (!player) return;
|
|
|
|
var pp = this._playerScreenPixel();
|
|
|
|
// Treasure proximity: on 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++) {
|
|
var d = this._fxDist(player.x, player.y, cells[i].x, cells[i].y);
|
|
if (d === 0) onTreasure = true;
|
|
else if (d <= 2) nearTreasure = true;
|
|
}
|
|
if (onTreasure && window.FX.burst) {
|
|
window.FX.burst(pp.x, pp.y, '#ffd700', 24);
|
|
} else if (nearTreasure && window.FX.sparkle) {
|
|
window.FX.sparkle(pp.x, pp.y, '#fbbf24');
|
|
}
|
|
|
|
// Monster proximity 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.
|
|
_fxStart: function() {
|
|
if (!window.FX) return;
|
|
this._fxLastMonsterFlash = 0;
|
|
try {
|
|
var base = this._baseCanvas();
|
|
if (base && window.FX.attach) window.FX.attach(base);
|
|
if (window.FX.setAmbient) window.FX.setAmbient(true);
|
|
if (window.FX.start) window.FX.start();
|
|
} catch (e) { /* best-effort */ }
|
|
},
|
|
|
|
// Stop and clear the FX loop so requestAnimationFrame does not leak.
|
|
_fxStop: function() {
|
|
if (!window.FX) return;
|
|
try {
|
|
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
|
if (window.FX.stop) window.FX.stop();
|
|
if (window.FX.clear) window.FX.clear();
|
|
} catch (e) { /* best-effort */ }
|
|
},
|
|
|
|
// Keep the overlay aligned and feed it the camera-centered player position.
|
|
_fxSyncLighting: function() {
|
|
if (!window.FX) return;
|
|
var base = this._baseCanvas();
|
|
if (!base) return;
|
|
if (window.FX.syncTo) window.FX.syncTo(base);
|
|
if (window.FX.renderLighting && GameState && GameState._player) {
|
|
var pp = this._playerScreenPixel();
|
|
window.FX.renderLighting(pp.x, pp.y, pp.cell, { torches: GameState._torches || [] });
|
|
}
|
|
},
|
|
|
|
// Debug helpers delegate to GameState.
|
|
debugPrintMap: function() {
|
|
GameState.debugPrintMap(this._screenWidth, this._screenHeight);
|
|
},
|
|
|
|
debugTileMapping: function() {
|
|
GameState.debugTileMapping();
|
|
},
|
|
|
|
// Accessors forwarding to GameState, kept for callers that read Game.* directly.
|
|
get _gameActive() {
|
|
return GameState.isGameActive();
|
|
},
|
|
|
|
set _gameActive(value) {
|
|
GameState.setGameActive(value);
|
|
},
|
|
|
|
get _player() {
|
|
return GameState._player;
|
|
},
|
|
|
|
get _monster() {
|
|
return GameState._monster;
|
|
},
|
|
|
|
get _map() {
|
|
return GameState._map;
|
|
},
|
|
|
|
get _items() {
|
|
return GameState._items;
|
|
},
|
|
|
|
get _visibleTiles() {
|
|
return GameState._visibleTiles;
|
|
},
|
|
|
|
get _exploredTiles() {
|
|
return GameState._exploredTiles;
|
|
}
|
|
};
|
|
|
|
// Payment state helpers
|
|
Game._pendingPaymentDetected = function(info) {
|
|
this._unconfirmedPayment = true;
|
|
this._unconfirmedPaymentInfo = info || {};
|
|
this._awaitingPayment = false; // A transaction exists, so this is no longer awaiting creation
|
|
};
|
|
|
|
Game._pendingPaymentConfirmed = function() {
|
|
this._unconfirmedPayment = false;
|
|
this._unconfirmedPaymentInfo = null;
|
|
this._awaitingPayment = false;
|
|
};
|
|
|
|
Game._paymentRequested = function() {
|
|
this._awaitingPayment = true;
|
|
};
|
|
|
|
// Debug utilities: log to console, to the on-page debug panel, and to the server.
|
|
const GameDebug = {
|
|
log: function(message) {
|
|
console.log(message);
|
|
this.updateDebugDisplay(message);
|
|
this.sendToServer(message);
|
|
},
|
|
|
|
updateDebugDisplay: function(message) {
|
|
const debugContent = document.getElementById('debug-content');
|
|
if (debugContent) {
|
|
const timestamp = new Date().toLocaleTimeString();
|
|
debugContent.innerHTML += `<div>[${timestamp}] ${message}</div>`;
|
|
// Cap the panel at the ten most recent messages.
|
|
const lines = debugContent.children;
|
|
if (lines.length > 10) {
|
|
debugContent.removeChild(lines[0]);
|
|
}
|
|
const debugDisplay = document.getElementById('debug-display');
|
|
debugDisplay.scrollTop = debugDisplay.scrollHeight;
|
|
}
|
|
},
|
|
|
|
sendToServer: function(message) {
|
|
try {
|
|
fetch('/debug', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ message: message })
|
|
}).catch(err => {
|
|
// Debug reporting is best-effort; an unavailable server must not surface an error.
|
|
});
|
|
} catch (err) {
|
|
// Debug reporting is best-effort.
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.Game = Game;
|
|
window.GameDebug = GameDebug;
|
|
}
|