/* global Phaser, PuzzleGenerator, PieceRenderer, PieceObject, GroupManager, SnapDetector, PuzzleState, StorageManager, getRoomCodeFromURL, NetworkManager */ const SNAP_RADIUS_FACTOR = 0.35; // fraction of pieceW const GLOW_RADIUS_FACTOR = 1.4; // fraction of pieceW const DRAG_THRESHOLD = 6; // pixels before treating as drag (not a second click) const PLAYER_COLORS = [ 0x4488ff, // blue 0xff6644, // red-orange 0x44cc66, // green 0xffaa22, // amber 0xcc44ff, // purple 0x44dddd, // cyan 0xff66aa, // pink 0xaacc44, // lime ]; function getPlayerColor(playerId) { return PLAYER_COLORS[(playerId - 1) % PLAYER_COLORS.length]; } class PuzzleScene extends Phaser.Scene { constructor() { super({ key: 'PuzzleScene' }); } /** Convert screen coordinates to world coordinates, accounting for zoom origin. */ _screenToWorld(screenX, screenY) { const cam = this.cameras.main; const ox = cam.width / 2; const oy = cam.height / 2; return { x: (screenX - ox) / cam.zoom + cam.scrollX + ox, y: (screenY - oy) / cam.zoom + cam.scrollY + oy, }; } // ─── Phaser lifecycle ──────────────────────────────────────────────── init(data) { this.cfg = data || {}; this._isNetworked = false; this._isHost = false; this._remoteClaims = new Map(); // pieceId -> playerId (pieces held by others) this._playerNames = new Map(); // playerId -> name this._remoteLabels = new Map(); // playerId -> { bg: Graphics, text: Text } // Reset state from any previous puzzle this._completed = false; this._completionTime = null; this._ready = false; this._heldPiece = null; this._isPanning = false; this._isBoxSelecting = false; this._boxStartWorld = null; this._boxSelectedIds = null; this._startTime = null; this._currentMusic = null; this._musicStarted = false; this._timerEl = null; this._playersListEl = null; this._connStatusEl = null; this._trackInfoEl = null; this._muteBtn = null; this._minZoom = null; // Network join from MainMenuScene if (this.cfg._networkJoin) { this._isNetworked = true; this._isHost = false; return; } // Check if we should restore from a URL room code const urlCode = getRoomCodeFromURL(); if (urlCode && !this.cfg.roomCode) { this.cfg.roomCode = urlCode; this.cfg._restore = true; } } preload() { // Sound effects if (!this.cache.audio.exists('sfx_click')) { this.load.audio('sfx_click', 'assets/audio/fx/click.mp3'); } if (!this.cache.audio.exists('sfx_grab')) { this.load.audio('sfx_grab', 'assets/audio/fx/grab.mp3'); } // Music track list if (!this.cache.json.exists('music_tracks')) { this.load.json('music_tracks', 'assets/audio/music/tracks.json'); } // Playfield background texture (default to dark wood) this._bgKey = this.cfg.bgKey || 'bg_dark_wood'; this._bgPath = this.cfg.bgPath || 'assets/images/ui/dark_wood.jpg'; if (!this.textures.exists(this._bgKey)) { this.load.image(this._bgKey, this._bgPath); } // Network join: load the image from the state received from server if (this.cfg._networkJoin && this.cfg._networkState) { const ns = this.cfg._networkState; this.cfg = Object.assign(this.cfg, { imageKey: ns.imageKey, imagePath: ns.imagePath, pieceCount: ns.pieceCount, roomCode: ns.roomCode, bgKey: ns.bgKey, bgPath: ns.bgPath, _networkJoin: true, _networkState: ns, }); // Re-derive background from network state this._bgKey = ns.bgKey || 'bg_dark_wood'; this._bgPath = ns.bgPath || 'assets/images/ui/dark_wood.jpg'; if (!this.textures.exists(this._bgKey)) { this.load.image(this._bgKey, this._bgPath); } if (!this.textures.exists(ns.imageKey)) { this.load.image(ns.imageKey, ns.imagePath); } return; } // If restoring from storage, the image may not be in the cache if (this.cfg._restore) { const saved = StorageManager.load(this.cfg.roomCode); if (saved) { this.cfg = Object.assign(this.cfg, saved); if (!this.textures.exists(saved.imageKey)) { this.load.image(saved.imageKey, saved.imagePath); } } } // Fresh start from NewPuzzleScene — load the full image if not cached if (!this.cfg._networkJoin && !this.cfg._restore) { if (!this.textures.exists(this.cfg.imageKey)) { this.load.image(this.cfg.imageKey, this.cfg.imagePath); } } // Loading progress text this._loadingText = this.add.text(960, 540, 'Loading 0%...', { fontFamily: 'Arial, sans-serif', fontSize: '36px', color: '#ffffff', stroke: '#000000', strokeThickness: 4, }).setOrigin(0.5).setDepth(9999); this._onLoadProgress = (value) => { if (this._loadingText) { this._loadingText.setText(`Loading ${Math.round(value * 100)}%...`); } }; this._onLoadComplete = () => { if (this._loadingText) { this._loadingText.destroy(); this._loadingText = null; } this.load.off('progress', this._onLoadProgress); this.load.off('complete', this._onLoadComplete); }; this.load.on('progress', this._onLoadProgress); this.load.on('complete', this._onLoadComplete); } create() { // Stop menu music if still playing from MainMenuScene/NewPuzzleScene try { if (this.sound.stopByKey) { this.sound.stopByKey('main_menu_music'); } else { (this.sound.sounds || []).forEach(s => { if (s.key === 'main_menu_music') s.stop(); }); } } catch (_) { /* ignore if not playing */ } const { width, height } = this.sys.game.config; // Show loading state until pieces are rendered this._loadBar = this._createLoadBar(width, height); // Determine saved state source: network join, localStorage restore, or fresh let saved = null; if (this.cfg._networkJoin && this.cfg._networkState) { saved = this.cfg._networkState; } else if (this.cfg._restore) { saved = StorageManager.load(this.cfg.roomCode); } // Generate puzzle data const gen = PuzzleGenerator.generate(this.cfg.pieceCount); this._cols = gen.cols; this._rows = gen.rows; this._pieces = saved ? saved.pieces : gen.pieces; const sourceImg = this.textures.get(this.cfg.imageKey).getSourceImage(); const imageW = sourceImg.naturalWidth || sourceImg.width; const imageH = sourceImg.naturalHeight || sourceImg.height; PieceRenderer.renderAll( this, sourceImg, this._pieces, this._cols, this._rows, imageW, imageH, (done, total) => this._updateLoadBar(done, total) ).then(({ pieceW, pieceH, tabSize, canvasW, canvasH }) => { this._pieceW = pieceW; this._pieceH = pieceH; this._tabSize = tabSize; this._canvasW = canvasW; this._canvasH = canvasH; this._destroyLoadBar(); this._buildScene(saved); }); } update() { if (!this._ready) return; // Update puzzle timer once per second this._updateTimer(); // Fallback: detect when music track has finished (Phaser 3.9 'complete' event can be unreliable) if (this._currentMusic && this._musicStarted && !this._currentMusic.isPlaying && !this._currentMusic.isPaused) { this._playNextTrack(); } const ptr = this.input.activePointer; // Box selection drawing (CTRL+drag) if (this._isBoxSelecting && ptr.isDown) { const curWorld = this._screenToWorld(ptr.x, ptr.y); const sx = this._boxStartWorld.x; const sy = this._boxStartWorld.y; const ex = curWorld.x; const ey = curWorld.y; const rx = Math.min(sx, ex); const ry = Math.min(sy, ey); const rw = Math.abs(ex - sx); const rh = Math.abs(ey - sy); this._boxGraphics.clear(); this._boxGraphics.lineStyle(2, 0x44aaff, 0.8); this._boxGraphics.fillStyle(0x44aaff, 0.12); this._boxGraphics.fillRect(rx, ry, rw, rh); this._boxGraphics.strokeRect(rx, ry, rw, rh); } // Camera pan (right-click drag, or left-click drag on empty space) if (this._isPanning && ptr.isDown && !this._heldPiece) { const cam = this.cameras.main; const pdx = (ptr.x - this._panLastX) / cam.zoom; const pdy = (ptr.y - this._panLastY) / cam.zoom; cam.scrollX -= pdx; cam.scrollY -= pdy; this._panLastX = ptr.x; this._panLastY = ptr.y; } if (this._heldPiece) { const cam = this.cameras.main; const dx = (ptr.x - this._lastPtrX) / cam.zoom; const dy = (ptr.y - this._lastPtrY) / cam.zoom; this._lastPtrX = ptr.x; this._lastPtrY = ptr.y; if (dx !== 0 || dy !== 0) { this._dragDistance += Math.abs(dx) + Math.abs(dy); this._moveHeldGroup(dx, dy); this._drawGlow(); // Broadcast movement to other players (throttled internally) if (this._isNetworked) { NetworkManager.sendMove(this._heldPiece.data.id, dx, dy); } } } } // ─── Scene construction ────────────────────────────────────────────── _buildScene(saved) { // Clear old saved puzzle when starting fresh (not restoring or joining) if (!saved) { StorageManager.clearCurrent(); } const { width, height } = this.sys.game.config; // Tile the background texture across the playfield, centered on the world const worldBounds = this._calcWorldBounds(); const bgSrc = this.textures.get(this._bgKey).getSourceImage(); const bgW = bgSrc.naturalWidth || bgSrc.width; const bgH = bgSrc.naturalHeight || bgSrc.height; const cx = worldBounds.w / 2; const cy = worldBounds.h / 2; // Tile outward from centre far enough to cover panning beyond the world const pad = Math.max(worldBounds.w, worldBounds.h); const halfTilesX = Math.ceil((worldBounds.w / 2 + pad) / bgW); const halfTilesY = Math.ceil((worldBounds.h / 2 + pad) / bgH); for (let ty = -halfTilesY; ty <= halfTilesY; ty++) { for (let tx = -halfTilesX; tx <= halfTilesX; tx++) { this.add.image(cx + tx * bgW, cy + ty * bgH, this._bgKey) .setDepth(-1); } } // Glow graphics (above pieces, below UI) this._glowGraphics = this.add.graphics().setDepth(500); // Create PieceObjects this._pieceObjects = new Map(); // pieceId → PieceObject this._pieces.forEach(pd => { const po = new PieceObject( this, pd, this._pieceW, this._pieceH, this._tabSize, this._canvasW, this._canvasH ); this._pieceObjects.set(pd.id, po); }); // GroupManager if (saved && saved.groups) { this._groupManager = GroupManager.deserialize(saved.groups); } else { this._groupManager = new GroupManager(); this._pieces.forEach(pd => this._groupManager.createSingletonGroup(pd.id)); } // Calculate world bounds and set up camera this._worldW = worldBounds.w; this._worldH = worldBounds.h; if (!saved) { this._shufflePieces(this._worldW, this._worldH); } else { // Sync visual positions for restored puzzles this._pieces.forEach(pd => { this._pieceObjects.get(pd.id).setPosition(pd.x, pd.y); }); } this._setupCamera(this._worldW, this._worldH); // Interaction state this._heldPiece = null; this._lastPtrX = 0; this._lastPtrY = 0; this._dragDistance = 0; this._justPickedUp = false; // true during the pointerup that completes the pickup click this._isPanning = false; this._panLastX = 0; this._panLastY = 0; this._ready = true; // Puzzle timer — restore from saved state or start fresh this._startTime = (saved && saved.startTime) ? saved.startTime : Date.now(); // Box selection state (CTRL+drag) this._isBoxSelecting = false; this._boxStartWorld = null; // { x, y } in world coords this._boxGraphics = this.add.graphics().setDepth(510).setScrollFactor(1); this._boxSelectedIds = null; // Set when multi-holding // Input this.input.on('gameobjectdown', this._onPieceDown, this); this.input.on('pointerdown', this._onPointerDown, this); this.input.on('pointerup', this._onPointerUp, this); // Right-click also starts pan (only when no piece is held) this.input.on('pointerdown', (ptr) => { if (ptr.rightButtonDown() && !this._heldPiece) { this._isPanning = true; this._panLastX = ptr.x; this._panLastY = ptr.y; } }); // Stop panning on any button release this.input.on('pointerup', () => { this._isPanning = false; }); // Disable context menu on the game canvas this.sys.game.canvas.addEventListener('contextmenu', e => e.preventDefault()); // Mouse wheel zoom (DOM event — Phaser 3.9 doesn't have input 'wheel') this._wheelHandler = (e) => { e.preventDefault(); if (this._completed || !this._minZoom) return; const cam = this.cameras.main; const oldZoom = cam.zoom; const ptr = this.input.activePointer; // Zoom in or out by 10% const factor = e.deltaY > 0 ? 0.9 : 1.1; const newZoom = Math.max(this._minZoom, Math.min(2.0, oldZoom * factor)); if (newZoom === oldZoom) return; // Zoom toward cursor: adjust scroll so the world point under the // cursor stays fixed. Phaser 3.9 camera zooms around its centre // (width/2, height/2), so the screen→world formula is: // worldX = (screenX - originX) / zoom + scrollX + originX // Solving for newScrollX: const originX = cam.width / 2; const originY = cam.height / 2; cam.scrollX += (ptr.x - originX) * (1 / oldZoom - 1 / newZoom); cam.scrollY += (ptr.y - originY) * (1 / oldZoom - 1 / newZoom); cam.setZoom(newZoom); }; // Use document-level listener — the canvas fills the viewport so all // wheel events on the page are for the game. document.addEventListener('wheel', this._wheelHandler, { passive: false }); // Music player this._setupMusic(); // DOM UI (immune to camera zoom) this._buildDomUI(); // Apply piece visual effects (outlines on singletons, shadows on groups) this._updatePieceEffects(); // Check already complete on restore if (saved && saved.completed) { this._showCompletion(false); } // ─── Networking setup ────────────────────────────────────────────── if (this.cfg._networkJoin) { // Joiner — already connected and joined via MainMenuScene this._isNetworked = true; this._isHost = false; // Populate player names from the join data if (this.cfg._networkPlayers) { this.cfg._networkPlayers.forEach(p => { if (p.playerId && p.playerName) { this._playerNames.set(p.playerId, p.playerName); } }); } this._setupNetworkListeners(); this._updatePlayersList(); } else if (NetworkManager.connected) { // Host — create the room on the server this._isNetworked = true; this._isHost = true; const state = new PuzzleState({ imageKey: this.cfg.imageKey, imagePath: this.cfg.imagePath, pieceCount: this.cfg.pieceCount, roomCode: this.cfg.roomCode, cols: this._cols, rows: this._rows, pieces: this._pieces, groups: this._groupManager.serialize(), completed: false, bgKey: this._bgKey, bgPath: this._bgPath, startTime: this._startTime, }); NetworkManager.createRoom(this.cfg.roomCode, state.serialize(), this.cfg.playerName); this._setupNetworkListeners(); } } // ─── Network event handlers ──────────────────────────────────────── _setupNetworkListeners() { this._netHandlers = { claim_ok: (msg) => this._onNetworkClaim(msg), claim_denied: (msg) => this._onNetworkClaimDenied(msg), move: (msg) => this._onNetworkMove(msg), release: (msg) => this._onNetworkRelease(msg), player_joined: (msg) => this._onNetworkPlayerJoined(msg), player_left: (msg) => this._onNetworkPlayerLeft(msg), completed: () => this._onNetworkCompleted(), }; for (const [event, handler] of Object.entries(this._netHandlers)) { NetworkManager.on(event, handler); } this.events.once('shutdown', () => { for (const [event, handler] of Object.entries(this._netHandlers)) { NetworkManager.off(event, handler); } }); } _onNetworkClaim(msg) { // Another player claimed a piece if (msg.playerId === NetworkManager.playerId) return; // Track player name if (msg.playerName) { this._playerNames.set(msg.playerId, msg.playerName); } const pieceId = msg.pieceId; const groupId = this._groupManager.getGroupId(pieceId); if (groupId === null) return; const color = getPlayerColor(msg.playerId); const peers = this._groupManager.getPeersOf(pieceId); peers.forEach(id => { this._remoteClaims.set(id, msg.playerId); const po = this._pieceObjects.get(id); if (po) po.image.setTint(color); }); // Create floating name label this._createPlayerLabel(msg.playerId, peers); } _onNetworkClaimDenied(msg) { // We tried to pick up a piece but it's held by another player — drop it if (this._heldPiece && this._heldPiece.data.id === msg.pieceId) { this._release(false); } } _onNetworkMove(msg) { if (msg.playerId === NetworkManager.playerId) return; const po = this._pieceObjects.get(msg.pieceId); if (!po) return; const peers = this._groupManager.getPeersOf(msg.pieceId); peers.forEach(id => { const p = this._pieceObjects.get(id); if (p) p.setPosition(p.data.x + msg.dx, p.data.y + msg.dy); }); // Shift the floating label by the same delta const label = this._remoteLabels.get(msg.playerId); if (label) { label.bg.x += msg.dx; label.bg.y += msg.dy; label.text.x += msg.dx; label.text.y += msg.dy; } } _onNetworkRelease(msg) { if (msg.playerId === NetworkManager.playerId) return; // Apply absolute positions if (msg.positions) { msg.positions.forEach(({ id, x, y }) => { const po = this._pieceObjects.get(id); if (po) po.setPosition(x, y); }); } // Rebuild groups from server state if (msg.groups) { const prevCount = this._groupManager.groupCount; this._groupManager.rebuildFromGroups(msg.groups); if (this._groupManager.groupCount < prevCount) { this.sound.play('sfx_click'); } this._updatePieceEffects(); } // Remove floating label this._destroyPlayerLabel(msg.playerId); // Clear remote claims for this player for (const [pid, playerId] of this._remoteClaims) { if (playerId === msg.playerId) { this._remoteClaims.delete(pid); const po = this._pieceObjects.get(pid); if (po) po.image.clearTint(); } } this._saveState(); } _updateConnStatus() { if (!this._connStatusEl) return; if (NetworkManager.connected) { this._connStatusEl.style.color = '#44aa66'; this._connStatusEl.textContent = 'Connected'; } else { this._connStatusEl.style.color = '#aa4444'; this._connStatusEl.textContent = 'Disconnected'; } } _onNetworkPlayerJoined(msg) { if (msg.playerName) { this._playerNames.set(msg.playerId, msg.playerName); } console.log(`${msg.playerName || 'Player ' + msg.playerId} joined the room`); this._updatePlayersList(); } _onNetworkPlayerLeft(msg) { // Remove floating label this._destroyPlayerLabel(msg.playerId); // Clear claims by the departed player for (const [pid, playerId] of this._remoteClaims) { if (playerId === msg.playerId) { this._remoteClaims.delete(pid); const po = this._pieceObjects.get(pid); if (po) po.image.clearTint(); } } this._playerNames.delete(msg.playerId); console.log(`Player ${msg.playerId} left the room`); this._updatePlayersList(); } _onNetworkCompleted() { if (!this._completed) { this._completed = true; StorageManager.clearCurrent(); this._showCompletion(true); } } // ─── Player label helpers ────────────────────────────────────────── _calcGroupCentroid(peers) { let sumX = 0, sumY = 0, count = 0; peers.forEach(id => { const po = this._pieceObjects.get(id); if (po) { sumX += po.data.x; sumY += po.data.y; count++; } }); return { x: sumX / count, y: sumY / count }; } _createPlayerLabel(playerId, peers) { // Remove any existing label for this player first this._destroyPlayerLabel(playerId, false); const name = this._playerNames.get(playerId) || `Player ${playerId}`; const color = getPlayerColor(playerId); const centroid = this._calcGroupCentroid(peers); const lx = centroid.x; const ly = centroid.y - this._pieceH * 0.7; const text = this.add.text(lx, ly - 55, name, { fontFamily: 'Arial, sans-serif', fontSize: '28px', fontStyle: 'bold', color: '#ffffff', stroke: '#000000', strokeThickness: 3, shadow: { offsetX: 2, offsetY: 2, color: '#000000', blur: 4, fill: true }, }); text.setOrigin(0.5); text.setDepth(551); text.setAlpha(0); const w = Math.max(text.width, name.length * 16) + 32; const h = 40; const bg = this.add.graphics(); // Dark background for high contrast, with player color as a bright border bg.fillStyle(0x000000, 0.8); bg.fillRect(-w / 2, -h / 2, w, h); bg.lineStyle(3, color, 1); bg.strokeRect(-w / 2, -h / 2, w, h); bg.setPosition(lx, ly); bg.setDepth(550); bg.setAlpha(0); this.tweens.add({ targets: [bg, text], alpha: 1, duration: 200 }); this._remoteLabels.set(playerId, { bg, text }); } _destroyPlayerLabel(playerId, animate) { const label = this._remoteLabels.get(playerId); if (!label) return; if (animate !== false) { this.tweens.add({ targets: [label.bg, label.text], alpha: 0, duration: 150, onComplete: () => { label.bg.destroy(); label.text.destroy(); } }); } else { label.bg.destroy(); label.text.destroy(); } this._remoteLabels.delete(playerId); } _shufflePieces(worldW, worldH) { const margin = Math.max(this._canvasW, this._canvasH) * 0.5; const safeX0 = margin; const safeX1 = worldW - margin; const safeY0 = margin; const safeY1 = worldH - margin; const safeW = safeX1 - safeX0; const safeH = safeY1 - safeY0; // Zone-based placement: divide world into a grid of zones const n = this._pieces.length; const zoneCols = Math.ceil(Math.sqrt(n * (safeW / safeH))); const zoneRows = Math.ceil(n / zoneCols); const zoneW = safeW / zoneCols; const zoneH = safeH / zoneRows; // Shuffle index array const indices = Array.from({ length: n }, (_, i) => i); for (let i = n - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } this._pieces.forEach((pd, i) => { const zone = indices[i]; const zc = zone % zoneCols; const zr = Math.floor(zone / zoneCols); const cx = safeX0 + zc * zoneW + zoneW / 2; const cy = safeY0 + zr * zoneH + zoneH / 2; const jitterX = (Math.random() - 0.5) * zoneW * 0.3; const jitterY = (Math.random() - 0.5) * zoneH * 0.3; this._pieceObjects.get(pd.id).setPosition( Math.max(safeX0, Math.min(safeX1, cx + jitterX)), Math.max(safeY0, Math.min(safeY1, cy + jitterY)) ); }); } // ─── Camera & world ────────────────────────────────────────────────── _calcWorldBounds() { const n = this._pieces.length; const cellW = this._canvasW * 1.1; // slight padding between cells const cellH = this._canvasH * 1.1; const aspect = 16 / 9; const cols = Math.ceil(Math.sqrt(n * aspect)); const rows = Math.ceil(n / cols); return { w: Math.max(1920, cols * cellW), h: Math.max(1080, rows * cellH) }; } _setupCamera(worldW, worldH) { const { width, height } = this.sys.game.config; const cam = this.cameras.main; // Zoom to fit the entire world on screen this._minZoom = Math.min(width / worldW, height / worldH); cam.setZoom(this._minZoom); // Centre camera on the world. // In Phaser 3.9 the camera centre in world space is: // centreX = scrollX + originX + (width/2 - originX)/zoom // which simplifies (originX = width/2) to: centreX = scrollX + width/2 // So to centre on worldW/2: scrollX = worldW/2 - width/2 cam.scrollX = worldW / 2 - width / 2; cam.scrollY = worldH / 2 - height / 2; } // ─── Input handlers ────────────────────────────────────────────────── // // Phaser fires gameobjectdown BEFORE the scene-level pointerdown. // We use _justPickedUp to mark the pointerup that completes the first // click (pickup) so we don't immediately release again. _onPointerDown(ptr) { if (ptr.rightButtonDown()) return; // handled by pan logic this._lastPtrX = ptr.x; this._lastPtrY = ptr.y; this._dragDistance = 0; // CTRL + left-click on empty space → start box selection if (ptr.event && ptr.event.ctrlKey && !this._heldPiece) { this._isBoxSelecting = true; this._boxStartWorld = this._screenToWorld(ptr.x, ptr.y); return; } // Left-click on empty space (no piece held) → start camera pan if (!this._heldPiece) { this._isPanning = true; this._panLastX = ptr.x; this._panLastY = ptr.y; } } _onPieceDown(ptr, gameObject) { if (!this._ready) return; const clickedPO = this._getPieceObjectForImage(gameObject); if (!clickedPO) return; if (this._heldPiece === null) { // First click: pick up this._pickUp(clickedPO); // Only suppress release if pickup succeeded this._justPickedUp = this._heldPiece !== null; } else { const heldGroupId = this._groupManager.getGroupId(this._heldPiece.data.id); const clickedGroupId = this._groupManager.getGroupId(clickedPO.data.id); if (heldGroupId === clickedGroupId) { // Second click on the held group — release will happen in _onPointerUp this._justPickedUp = false; } else { // Clicked a different piece — swap (no snap on old piece) this._release(false); this._pickUp(clickedPO); this._justPickedUp = this._heldPiece !== null; } } } _onPointerUp() { // Finalize box selection if (this._isBoxSelecting) { this._isBoxSelecting = false; this._boxGraphics.clear(); this._finalizeBoxSelection(); return; } if (!this._ready || !this._heldPiece) return; if (this._justPickedUp && this._dragDistance < DRAG_THRESHOLD) { // This pointerup is the tail of the pickup click — don't release this._justPickedUp = false; return; } this._justPickedUp = false; this._release(true); } _getPieceObjectForImage(gameObject) { for (const po of this._pieceObjects.values()) { if (po.image === gameObject) return po; } return null; } // ─── Box selection ─────────────────────────────────────────────────── _finalizeBoxSelection() { const ptr = this.input.activePointer; const endWorld = this._screenToWorld(ptr.x, ptr.y); const sx = this._boxStartWorld.x; const sy = this._boxStartWorld.y; const minX = Math.min(sx, endWorld.x); const minY = Math.min(sy, endWorld.y); const maxX = Math.max(sx, endWorld.x); const maxY = Math.max(sy, endWorld.y); // Too small a box — ignore if ((maxX - minX) < 5 && (maxY - minY) < 5) return; // Find all pieces whose centers fall within the box const selectedIds = new Set(); this._pieceObjects.forEach((po, id) => { if (this._remoteClaims.has(id)) return; // skip pieces held by other players const px = po.data.x; const py = po.data.y; if (px >= minX && px <= maxX && py >= minY && py <= maxY) { // Add this piece and all its group peers const peers = this._groupManager.getPeersOf(id); peers.forEach(pid => selectedIds.add(pid)); } }); if (selectedIds.size === 0) return; // Pick the first piece as the anchor for the held-piece system const anchorId = selectedIds.values().next().value; const anchorPO = this._pieceObjects.get(anchorId); this._boxSelectedIds = selectedIds; this._heldPiece = anchorPO; this._dragDistance = 0; this._justPickedUp = true; this.sound.play('sfx_grab', { volume: 0.3 }); const ptrNow = this.input.activePointer; this._lastPtrX = ptrNow.x; this._lastPtrY = ptrNow.y; // Raise depth of all selected pieces let depthOffset = 0; selectedIds.forEach(id => { this._pieceObjects.get(id).setDepth(600 + depthOffset); depthOffset++; }); // Network: claim the anchor piece if (this._isNetworked) { NetworkManager.claimPiece(anchorPO.data.id); } } // ─── Pick-up / release ─────────────────────────────────────────────── _pickUp(pieceObj) { // If another player holds this piece, refuse pickup if (this._remoteClaims.has(pieceObj.data.id)) return; this._heldPiece = pieceObj; this._dragDistance = 0; this.sound.play('sfx_grab', { volume: 0.3 }); // Seed last pointer position so the first update() frame has zero delta const ptr = this.input.activePointer; this._lastPtrX = ptr.x; this._lastPtrY = ptr.y; const peers = this._groupManager.getPeersOf(pieceObj.data.id); let depthOffset = 0; peers.forEach(id => { this._pieceObjects.get(id).setDepth(600 + depthOffset); depthOffset++; }); // Send claim to server if (this._isNetworked) { NetworkManager.claimPiece(pieceObj.data.id); } } _release(trySnap) { if (!this._heldPiece) return; const releasedPieceId = this._heldPiece.data.id; if (this._boxSelectedIds) { // Box selection release: snap and reset each distinct group independently const processedGroups = new Set(); if (trySnap) { // Collect distinct groups from the selection const groupAnchors = []; this._boxSelectedIds.forEach(id => { const groupId = this._groupManager.getGroupId(id); if (!processedGroups.has(groupId)) { processedGroups.add(groupId); groupAnchors.push(id); } }); // Snap each group independently groupAnchors.forEach(anchorId => { const fakePiece = this._pieceObjects.get(anchorId); const saved = this._heldPiece; this._heldPiece = fakePiece; this._doSnap(); this._heldPiece = saved; }); } // Reset depth for all selected pieces this._boxSelectedIds.forEach(id => { this._pieceObjects.get(id).setDepth(0); }); // Broadcast release for each distinct group if (this._isNetworked) { const sentGroups = new Set(); this._boxSelectedIds.forEach(id => { const groupId = this._groupManager.getGroupId(id); if (!sentGroups.has(groupId)) { sentGroups.add(groupId); const peers = this._groupManager.getPeersOf(id); const positions = []; peers.forEach(pid => { const po = this._pieceObjects.get(pid); positions.push({ id: pid, x: po.data.x, y: po.data.y }); }); NetworkManager.sendRelease(id, positions, this._groupManager.serialize()); } }); } this._boxSelectedIds = null; } else { // Normal single-piece/group release if (trySnap) { this._doSnap(); } // Reset depth const peers = this._groupManager.getPeersOf(releasedPieceId); peers.forEach(id => this._pieceObjects.get(id).setDepth(0)); // Broadcast release with final positions and group state if (this._isNetworked) { const positions = []; peers.forEach(id => { const po = this._pieceObjects.get(id); positions.push({ id, x: po.data.x, y: po.data.y }); }); NetworkManager.sendRelease( releasedPieceId, positions, this._groupManager.serialize() ); } } this._glowGraphics.clear(); this._heldPiece = null; // Refresh shadows after piece positions changed this._updatePieceEffects(); this._saveState(); this._checkCompletion(); } _moveHeldGroup(dx, dy) { // If box-selected, move all selected pieces; otherwise just the held group const ids = this._boxSelectedIds || this._groupManager.getPeersOf(this._heldPiece.data.id); ids.forEach(id => { const po = this._pieceObjects.get(id); po.setPosition(po.data.x + dx, po.data.y + dy); }); } // ─── Snap ──────────────────────────────────────────────────────────── _doSnap() { const snapRadius = this._pieceW * SNAP_RADIUS_FACTOR; let snapped = true; // Cascade: keep snapping until no more candidates (handles multi-neighbor drops) while (snapped) { snapped = false; const candidates = SnapDetector.check( this._heldPiece, this._pieceObjects, this._groupManager, this._cols, this._pieceW, this._pieceH, snapRadius ); if (candidates.length === 0) break; const best = candidates[0]; const { shiftX, shiftY, heldId, neighborId } = best; // Shift the entire held group const peers = this._groupManager.getPeersOf(heldId); peers.forEach(id => { const po = this._pieceObjects.get(id); po.setPosition(po.data.x + shiftX, po.data.y + shiftY); }); // Merge groups this._groupManager.merge(heldId, neighborId); this.sound.play('sfx_click'); snapped = true; // Update heldPiece reference so getPeersOf returns the enlarged group // (heldPiece pointer stays the same object, groupManager now returns merged group) } this._updatePieceEffects(); } // ─── Piece visual effects ───────────────────────────────────────────── /** * Swap merged pieces to clean textures (no outline) and * redraw drop shadows behind merged groups. */ _updatePieceEffects() { this._groupManager.groups.forEach((members) => { const isMerged = members.size > 1; members.forEach(id => { const po = this._pieceObjects.get(id); if (!po) return; const expectedKey = isMerged ? `piece_clean_${id}` : `piece_${id}`; if (po.image.texture.key !== expectedKey && this.textures.exists(expectedKey)) { po.image.setTexture(expectedKey); } }); }); } // ─── Glow ──────────────────────────────────────────────────────────── _drawGlow() { this._glowGraphics.clear(); if (!this._heldPiece || this._boxSelectedIds) return; const glowRadius = this._pieceW * GLOW_RADIUS_FACTOR; const segments = SnapDetector.getGlowSegments( this._heldPiece, this._pieceObjects, this._groupManager, this._cols, this._pieceW, this._pieceH, glowRadius ); segments.forEach(({ x0, y0, x1, y1 }) => { // Four passes: wide outer, outer, mid, inner glow [ [20, 0x00ccff, 0.06], [14, 0x00ccff, 0.12], [8, 0x00eeff, 0.30], [4, 0xaaffff, 0.70], ].forEach(([lw, color, alpha]) => { this._glowGraphics.lineStyle(lw, color, alpha); this._glowGraphics.beginPath(); this._glowGraphics.moveTo(x0, y0); this._glowGraphics.lineTo(x1, y1); this._glowGraphics.strokePath(); }); }); } // ─── Completion ────────────────────────────────────────────────────── _checkCompletion() { if (this._completed) return; const gm = this._groupManager; if (gm.groupCount === 1 && gm.pieceCount === this._pieces.length) { this._completed = true; // Freeze the timer this._completionTime = Date.now() - this._startTime; StorageManager.clearCurrent(); this._showCompletion(true); } } _showCompletion(animate) { const { width, height } = this.sys.game.config; const cam = this.cameras.main; // Solved puzzle centre in world space const solvedCX = this._worldW / 2; const solvedCY = this._worldH / 2; const originX = solvedCX - (this._cols * this._pieceW) / 2 + this._pieceW / 2; const originY = solvedCY - (this._rows * this._pieceH) / 2 + this._pieceH / 2; if (animate) { // Zoom camera back to 1:1 and centre on the solved puzzle this.tweens.add({ targets: cam, zoom: 1.0, scrollX: solvedCX - width / 2, scrollY: solvedCY - height / 2, duration: 800, ease: 'Power2' }); // Tween all pieces to their solved positions this._pieces.forEach(pd => { const po = this._pieceObjects.get(pd.id); const solvedX = originX + pd.gridCol * this._pieceW; const solvedY = originY + pd.gridRow * this._pieceH; this.tweens.add({ targets: po.image, x: solvedX, y: solvedY, duration: 600, delay: 300, // wait for zoom to start first ease: 'Power2', onUpdate: () => { pd.x = po.image.x; pd.y = po.image.y; } }); po.setDepth(0); }); // Launch confetti after tween this.time.delayedCall(1100, () => this._launchConfetti(width, height)); } else { // Snap camera to 1:1 immediately for restored puzzles cam.setZoom(1.0); cam.scrollX = solvedCX - width / 2; cam.scrollY = solvedCY - height / 2; } // DOM completion overlay this.time.delayedCall(animate ? 1300 : 0, () => this._showDomCompletion()); } // ─── Room Stats helpers ────────────────────────────────────────────── _updateTimer() { if (!this._timerEl || !this._startTime || this._completed) return; const elapsed = Math.floor((Date.now() - this._startTime) / 1000); this._timerEl.textContent = this._formatTime(elapsed); } _formatTime(totalSeconds) { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = totalSeconds % 60; return String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0'); } _updatePlayersList() { if (!this._playersListEl) return; const names = []; // Add local player first if (this.cfg.playerName) { names.push(this.cfg.playerName + ' (you)'); } // Add remote players this._playerNames.forEach((name) => { names.push(name); }); if (names.length === 0) { this._playersListEl.textContent = '\u2014'; } else { this._playersListEl.innerHTML = ''; names.forEach(n => { const row = document.createElement('div'); row.textContent = n; this._playersListEl.appendChild(row); }); } } // ─── DOM UI ────────────────────────────────────────────────────────── // ─── Music ────────────────────────────────────────────────────────── _setupMusic() { const trackData = this.cache.json.get('music_tracks') || []; this._musicTracks = trackData.slice(); this._musicQueue = []; this._musicMuted = localStorage.getItem('ipuzzle_musicMuted') === 'true'; this._currentMusic = null; this._trackInfoEl = null; // Load all tracks that aren't cached yet let needsLoad = false; this._musicTracks.forEach((track, i) => { const key = `music_${i}`; if (!this.cache.audio.exists(key)) { this.load.audio(key, track.path); needsLoad = true; } }); if (needsLoad) { this.load.once('complete', () => this._playNextTrack()); this.load.start(); } else { // Small delay so the scene is fully ready this.time.delayedCall(100, () => this._playNextTrack()); } // Stop music when scene shuts down this.events.once('shutdown', () => { if (this._currentMusic) { this._currentMusic.stop(); this._currentMusic = null; } }); } _shuffleQueue() { // Build a fresh shuffled queue of track indices this._musicQueue = this._musicTracks.map((_, i) => i); for (let i = this._musicQueue.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [this._musicQueue[i], this._musicQueue[j]] = [this._musicQueue[j], this._musicQueue[i]]; } } _playNextTrack() { if (this._musicTracks.length === 0) return; // Stop current track if (this._currentMusic) { this._currentMusic.stop(); this._currentMusic = null; } // Refill queue if empty if (this._musicQueue.length === 0) { this._shuffleQueue(); } const idx = this._musicQueue.shift(); const key = `music_${idx}`; this._musicStarted = false; this._currentMusic = this.sound.add(key, { volume: 0.3 }); this._currentMusic.setMute(this._musicMuted); this._currentMusic.play(); this._musicStarted = true; // Update track info display const track = this._musicTracks[idx]; if (this._trackInfoEl && track) { this._trackInfoEl.textContent = `${track.title} — ${track.artist}`; } // When track ends, play next (both event and polling fallback) this._currentMusic.once('complete', () => { if (this._currentMusic && this._currentMusic.key === key) { this._playNextTrack(); } }); } _toggleMute() { this._musicMuted = !this._musicMuted; localStorage.setItem('ipuzzle_musicMuted', this._musicMuted); if (this._currentMusic) { this._currentMusic.setMute(this._musicMuted); } } _skipTrack() { this._playNextTrack(); } // ─── DOM UI ────────────────────────────────────────────────────────── _buildDomUI() { // Overlay div that mirrors the canvas CSS dimensions exactly. // pointer-events:none lets clicks pass through to the canvas everywhere // except on the interactive children. this._uiLayer = document.createElement('div'); Object.assign(this._uiLayer.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '100vw', height: '56.25vw', maxHeight: '100vh', maxWidth: '177.78vh', pointerEvents: 'none', zIndex: '10', fontFamily: 'Arial, sans-serif', }); document.body.appendChild(this._uiLayer); // Room code + share link — bottom-right const roomCode = this.cfg.roomCode; const shareUrl = `${window.location.origin}${window.location.pathname}?room=${roomCode}`; const roomEl = document.createElement('div'); Object.assign(roomEl.style, { position: 'absolute', bottom: '48px', right: '20px', color: '#ddeeff', fontSize: '26px', fontFamily: 'monospace', fontWeight: 'bold', letterSpacing: '0.15em', background: 'rgba(0, 0, 0, 0.55)', padding: '6px 14px', borderRadius: '4px 4px 0 0', }); roomEl.textContent = `Room: ${roomCode}`; this._uiLayer.appendChild(roomEl); const linkRow = document.createElement('div'); Object.assign(linkRow.style, { position: 'absolute', bottom: '16px', right: '20px', display: 'flex', alignItems: 'center', gap: '8px', background: 'rgba(0, 0, 0, 0.55)', padding: '5px 14px', borderRadius: '0 0 4px 4px', pointerEvents: 'auto', }); const linkText = document.createElement('span'); Object.assign(linkText.style, { color: '#8899bb', fontSize: '14px', fontFamily: 'monospace', userSelect: 'all', }); linkText.textContent = shareUrl; const copyBtn = document.createElement('button'); Object.assign(copyBtn.style, { background: 'none', border: 'none', color: '#8899bb', fontSize: '16px', cursor: 'pointer', padding: '2px 4px', lineHeight: '1', }); copyBtn.textContent = '\uD83D\uDCCB'; copyBtn.title = 'Copy link'; copyBtn.addEventListener('mouseenter', () => { copyBtn.style.color = '#ddeeff'; }); copyBtn.addEventListener('mouseleave', () => { copyBtn.style.color = '#8899bb'; }); copyBtn.addEventListener('click', (e) => { e.stopPropagation(); const showCopied = () => { copyBtn.textContent = '\u2713 Copied!'; copyBtn.style.color = '#44cc66'; setTimeout(() => { copyBtn.textContent = '\uD83D\uDCCB'; copyBtn.style.color = '#8899bb'; }, 2000); }; // Try modern clipboard API first, fall back to execCommand if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(shareUrl).then(showCopied).catch(() => { // Fallback for non-secure contexts const temp = document.createElement('textarea'); temp.value = shareUrl; temp.style.position = 'fixed'; temp.style.opacity = '0'; document.body.appendChild(temp); temp.select(); document.execCommand('copy'); document.body.removeChild(temp); showCopied(); }); } else { const temp = document.createElement('textarea'); temp.value = shareUrl; temp.style.position = 'fixed'; temp.style.opacity = '0'; document.body.appendChild(temp); temp.select(); document.execCommand('copy'); document.body.removeChild(temp); showCopied(); } }); linkRow.appendChild(linkText); linkRow.appendChild(copyBtn); this._uiLayer.appendChild(linkRow); // Connection status indicator (only for networked games) if (this._isNetworked) { this._connStatusEl = document.createElement('div'); Object.assign(this._connStatusEl.style, { position: 'absolute', bottom: '1%', right: '8%', fontSize: '1.2vmin', fontFamily: 'Arial, sans-serif', }); this._updateConnStatus(); this._uiLayer.appendChild(this._connStatusEl); this._connHandler = () => this._updateConnStatus(); this._disconnHandler = () => this._updateConnStatus(); NetworkManager.on('connected', this._connHandler); NetworkManager.on('disconnected', this._disconnHandler); this.events.once('shutdown', () => { NetworkManager.off('connected', this._connHandler); NetworkManager.off('disconnected', this._disconnHandler); }); } // Back button — top-left const backBtn = this._makeDomBtn('← Menu', () => { this._saveState(); this.scene.start('MainMenuScene'); }); Object.assign(backBtn.style, { top: '10px', left: '10px' }); this._uiLayer.appendChild(backBtn); // Room Stats panel — below menu button // States: 'expanded' → 'folded' (content hidden, title visible) → 'collapsed' (icon only) const statsPanel = document.createElement('div'); Object.assign(statsPanel.style, { position: 'absolute', top: '50px', left: '10px', background: 'rgba(0, 0, 0, 0.55)', padding: '10px 16px', borderRadius: '4px', fontFamily: 'Arial, sans-serif', color: '#ddeeff', fontSize: '14px', lineHeight: '1.6', overflow: 'hidden', transition: 'width 0.3s ease, padding 0.3s ease', width: 'auto', }); // Title row with collapse toggle const statsHeader = document.createElement('div'); Object.assign(statsHeader.style, { display: 'flex', alignItems: 'center', cursor: 'pointer', pointerEvents: 'auto', userSelect: 'none', whiteSpace: 'nowrap', overflow: 'hidden', }); const toggleIcon = document.createElement('span'); Object.assign(toggleIcon.style, { fontSize: '14px', color: '#8899bb', display: 'inline-block', flexShrink: '0', }); toggleIcon.textContent = '\u25B2'; // ▲ const statsTitle = document.createElement('span'); Object.assign(statsTitle.style, { fontWeight: 'bold', fontSize: '15px', color: '#ddeeff', letterSpacing: '0.05em', marginLeft: '8px', overflow: 'hidden', transition: 'max-width 0.3s ease, opacity 0.25s ease, margin 0.3s ease', display: 'inline-block', maxWidth: '200px', opacity: '1', }); statsTitle.textContent = 'Room Stats'; statsHeader.appendChild(toggleIcon); statsHeader.appendChild(statsTitle); statsPanel.appendChild(statsHeader); // Collapsible content wrapper const statsContent = document.createElement('div'); Object.assign(statsContent.style, { overflow: 'hidden', transition: 'max-height 0.3s ease, opacity 0.25s ease', opacity: '1', }); // Puzzle Time const timeLabel = document.createElement('div'); Object.assign(timeLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '6px' }); timeLabel.textContent = 'Puzzle Time'; statsContent.appendChild(timeLabel); this._timerEl = document.createElement('div'); Object.assign(this._timerEl.style, { color: '#ddeeff', fontSize: '16px', fontFamily: 'monospace', fontWeight: 'bold', }); this._timerEl.textContent = '00:00:00'; statsContent.appendChild(this._timerEl); // Players const playersLabel = document.createElement('div'); Object.assign(playersLabel.style, { color: '#8899bb', fontSize: '12px', marginTop: '8px' }); playersLabel.textContent = 'Players'; statsContent.appendChild(playersLabel); this._playersListEl = document.createElement('div'); Object.assign(this._playersListEl.style, { color: '#ddeeff', fontSize: '13px', }); statsContent.appendChild(this._playersListEl); statsPanel.appendChild(statsContent); // Measure natural height after first render requestAnimationFrame(() => { statsContent.style.maxHeight = statsContent.scrollHeight + 'px'; }); // Collapse / expand state machine let statsState = 'expanded'; // 'expanded' | 'folded' | 'collapsed' let animating = false; const collapseContent = () => { // Phase 1: fold content up animating = true; statsContent.style.maxHeight = statsContent.scrollHeight + 'px'; statsContent.offsetHeight; // eslint-disable-line no-unused-expressions statsContent.style.maxHeight = '0px'; statsContent.style.opacity = '0'; toggleIcon.textContent = '\u25BC'; // ▼ statsContent.addEventListener('transitionend', function onFolded(ev) { if (ev.propertyName !== 'max-height') return; statsContent.removeEventListener('transitionend', onFolded); statsState = 'folded'; // Phase 2: collapse title horizontally collapseTitle(); }); }; const collapseTitle = () => { // Snapshot current width so we can animate from it const currentW = statsPanel.offsetWidth; statsPanel.style.width = currentW + 'px'; statsPanel.offsetHeight; // eslint-disable-line no-unused-expressions statsTitle.style.maxWidth = '0px'; statsTitle.style.opacity = '0'; statsTitle.style.marginLeft = '0px'; // Icon is ~14px + 8px padding each side = 30px statsPanel.style.width = '30px'; statsPanel.style.padding = '8px'; statsTitle.addEventListener('transitionend', function onTitleDone(ev) { if (ev.propertyName !== 'max-width') return; statsTitle.removeEventListener('transitionend', onTitleDone); toggleIcon.textContent = '\u25B6'; // ▶ statsState = 'collapsed'; animating = false; }); }; const expandTitle = () => { // Phase 1: expand title horizontally animating = true; toggleIcon.textContent = '\u25BC'; // ▼ statsPanel.style.padding = '10px 16px'; statsPanel.style.width = 'auto'; // Measure the full expanded width, then animate to it const fullW = statsPanel.offsetWidth; statsPanel.style.width = '30px'; statsPanel.offsetHeight; // eslint-disable-line no-unused-expressions statsPanel.style.width = fullW + 'px'; statsTitle.style.maxWidth = '200px'; statsTitle.style.opacity = '1'; statsTitle.style.marginLeft = '8px'; statsTitle.addEventListener('transitionend', function onTitleBack(ev) { if (ev.propertyName !== 'max-width') return; statsTitle.removeEventListener('transitionend', onTitleBack); statsPanel.style.width = 'auto'; statsState = 'folded'; // Phase 2: expand content expandContent(); }); }; const expandContent = () => { // Measure natural height statsContent.style.transition = 'none'; statsContent.style.maxHeight = 'none'; const fullHeight = statsContent.scrollHeight; statsContent.style.maxHeight = '0px'; statsContent.offsetHeight; // eslint-disable-line no-unused-expressions statsContent.style.transition = 'max-height 0.3s ease, opacity 0.25s ease'; statsContent.style.maxHeight = fullHeight + 'px'; statsContent.style.opacity = '1'; toggleIcon.textContent = '\u25B2'; // ▲ statsContent.addEventListener('transitionend', function onExpanded(ev) { if (ev.propertyName !== 'max-height') return; statsContent.removeEventListener('transitionend', onExpanded); statsContent.style.maxHeight = 'none'; statsState = 'expanded'; animating = false; }); }; statsHeader.addEventListener('click', (e) => { e.stopPropagation(); if (animating) return; if (statsState === 'expanded') { collapseContent(); } else if (statsState === 'collapsed') { expandTitle(); } }); // Hover feedback on toggle icon statsHeader.addEventListener('mouseenter', () => { toggleIcon.style.color = '#ddeeff'; }); statsHeader.addEventListener('mouseleave', () => { toggleIcon.style.color = '#8899bb'; }); this._uiLayer.appendChild(statsPanel); this._updatePlayersList(); // Music controls — top-right const musicRow = document.createElement('div'); Object.assign(musicRow.style, { position: 'absolute', top: '1%', right: '0.5%', display: 'flex', gap: '0.5vmin', pointerEvents: 'auto', }); const btnStyle = { background: '#1565c0', color: '#ddeeff', border: '1px solid #64b5f6', borderRadius: '4px', fontSize: '1.8vmin', fontFamily: 'Arial, sans-serif', cursor: 'pointer', padding: '0.4vmin 0.8vmin', lineHeight: '1', }; // Skip button const skipBtn = document.createElement('button'); skipBtn.textContent = '\u23ED'; // next track symbol skipBtn.title = 'Next Track'; Object.assign(skipBtn.style, btnStyle); skipBtn.addEventListener('mouseenter', () => { skipBtn.style.background = '#1e88e5'; }); skipBtn.addEventListener('mouseleave', () => { skipBtn.style.background = '#1565c0'; }); skipBtn.addEventListener('click', () => this._skipTrack()); // Mute/unmute button this._muteBtn = document.createElement('button'); this._muteBtn.title = 'Mute / Unmute'; Object.assign(this._muteBtn.style, btnStyle); this._muteBtn.addEventListener('mouseenter', () => { this._muteBtn.style.background = '#1e88e5'; }); this._muteBtn.addEventListener('mouseleave', () => { this._muteBtn.style.background = '#1565c0'; }); this._muteBtn.addEventListener('click', () => { this._toggleMute(); this._muteBtn.textContent = this._musicMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A'; }); this._muteBtn.textContent = this._musicMuted ? '\uD83D\uDD07' : '\uD83D\uDD0A'; musicRow.appendChild(skipBtn); musicRow.appendChild(this._muteBtn); this._uiLayer.appendChild(musicRow); // Track info display — below music controls this._trackInfoEl = document.createElement('div'); Object.assign(this._trackInfoEl.style, { position: 'absolute', top: '4.5%', right: '0.5%', color: '#8899bb', fontSize: '1.3vmin', fontFamily: 'Arial, sans-serif', textAlign: 'right', pointerEvents: 'none', maxWidth: '20%', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis', }); this._trackInfoEl.textContent = ''; this._uiLayer.appendChild(this._trackInfoEl); // Clean up DOM when the scene shuts down this.events.once('shutdown', () => this._destroyDomUI()); } _showDomCompletion() { // Full-screen overlay — blocks clicks from reaching the canvas const overlay = document.createElement('div'); Object.assign(overlay.style, { position: 'absolute', inset: '0', background: 'rgba(0,0,0,0.55)', pointerEvents: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: '1.5vmin', }); const title = document.createElement('div'); Object.assign(title.style, { color: '#ffffaa', fontSize: '5.5vmin', fontFamily: 'Georgia, serif', textShadow: '0 0 8px #886600, 0 2px 0 #886600', fontWeight: 'bold', }); title.textContent = 'Puzzle Complete!'; const timeLabel = document.createElement('div'); Object.assign(timeLabel.style, { color: '#aaccff', fontSize: '2.5vmin', fontFamily: 'Arial, sans-serif', }); const totalSec = Math.floor((this._completionTime || 0) / 1000); timeLabel.textContent = `Total Puzzle Time: ${this._formatTime(totalSec)}`; const btnRow = document.createElement('div'); Object.assign(btnRow.style, { display: 'flex', gap: '2vmin', marginTop: '1vmin', }); const newBtn = this._makeDomBtn('New Puzzle', () => this.scene.start('NewPuzzleScene')); const menuBtn = this._makeDomBtn('Main Menu', () => this.scene.start('MainMenuScene')); // In the flex row, buttons don't need absolute positioning newBtn.style.position = 'static'; menuBtn.style.position = 'static'; btnRow.appendChild(newBtn); btnRow.appendChild(menuBtn); overlay.appendChild(title); overlay.appendChild(timeLabel); overlay.appendChild(btnRow); this._uiLayer.appendChild(overlay); } _makeDomBtn(label, onClick) { const btn = document.createElement('button'); btn.textContent = label; Object.assign(btn.style, { position: 'absolute', padding: '0.8vmin 2vmin', background: '#1565c0', color: '#ddeeff', border: '1px solid #64b5f6', borderRadius: '4px', fontSize: '1.6vmin', fontFamily: 'Arial, sans-serif', cursor: 'pointer', pointerEvents: 'auto', whiteSpace: 'nowrap', }); // Hover effect btn.addEventListener('mouseenter', () => { btn.style.background = '#1e88e5'; btn.style.borderColor = '#64b5f6'; }); btn.addEventListener('mouseleave', () => { btn.style.background = '#1565c0'; btn.style.borderColor = '#64b5f6'; }); btn.addEventListener('click', onClick); return btn; } _destroyDomUI() { if (this._uiLayer && this._uiLayer.parentNode) { this._uiLayer.parentNode.removeChild(this._uiLayer); } this._uiLayer = null; document.removeEventListener('wheel', this._wheelHandler); } _launchConfetti(width, height) { const colors = [0xff4444, 0x44ff44, 0x4444ff, 0xffff44, 0xff44ff, 0x44ffff, 0xffffff]; // Generate tiny solid-color textures for each confetti color const keys = colors.map((color, i) => { const key = `confetti_${i}`; if (!this.textures.exists(key)) { const gfx = this.make.graphics({ x: 0, y: 0 }, false); gfx.fillStyle(color, 1); gfx.fillRect(0, 0, 8, 8); gfx.generateTexture(key, 8, 8); gfx.destroy(); } return key; }); // Create one emitter per color keys.forEach(key => { const manager = this.add.particles(key); manager.setDepth(1003); const emitter = manager.createEmitter({ x: { min: 0, max: width }, y: -10, speedY: { min: 80, max: 220 }, speedX: { min: -60, max: 60 }, rotate: { min: 0, max: 360 }, scale: { start: 1, end: 0.3 }, alpha: { start: 1, end: 0 }, lifespan: 2200, quantity: 2, frequency: 50, gravityY: 60 }); this.time.delayedCall(2500, () => { emitter.on = false; }); }); } // ─── Persistence ───────────────────────────────────────────────────── _saveState() { const state = new PuzzleState({ imageKey: this.cfg.imageKey, imagePath: this.cfg.imagePath, pieceCount: this.cfg.pieceCount, roomCode: this.cfg.roomCode, cols: this._cols, rows: this._rows, pieces: this._pieces, groups: this._groupManager.serialize(), completed: this._completed || false, bgKey: this._bgKey, bgPath: this._bgPath, startTime: this._startTime, }); StorageManager.save(state); StorageManager.saveCurrent(this.cfg.roomCode); } // ─── Loading bar ───────────────────────────────────────────────────── _createLoadBar(width, height) { const barW = 400, barH = 24; const bx = width / 2 - barW / 2; const by = height / 2 - barH / 2; const bg = this.add.graphics().setScrollFactor(0); bg.fillStyle(0x111133, 1); bg.lineStyle(1, 0x333366, 1); bg.fillRect(bx, by, barW, barH); bg.strokeRect(bx, by, barW, barH); const fill = this.add.graphics().setScrollFactor(0); const label = this.add.text(width / 2, height / 2 - 30, 'Generating pieces…', { fontFamily: 'Arial, sans-serif', fontSize: '18px', color: '#8899cc' }).setOrigin(0.5).setScrollFactor(0); return { bg, fill, label, bx, by, barW, barH }; } _updateLoadBar(done, total) { if (!this._loadBar) return; const { fill, bx, by, barW, barH } = this._loadBar; const pct = done / total; fill.clear(); fill.fillStyle(0x3366cc, 1); fill.fillRect(bx + 2, by + 2, (barW - 4) * pct, barH - 4); } _destroyLoadBar() { if (!this._loadBar) return; const { bg, fill, label } = this._loadBar; bg.destroy(); fill.destroy(); label.destroy(); this._loadBar = null; } }