// 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 += `