iPuzzle/js/scenes/PuzzleScene.js

3039 lines
101 KiB
JavaScript

/* 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;
// Stats tracking
this._stats = {
totalSnaps: 0,
snapTimestamps: [],
playerSnaps: new Map(),
cornersFound: null, // { time, playerName }
areaCompleteUL: null,
areaCompleteUR: null,
areaCompleteLR: null,
areaCompleteLL: null,
edgeComplete: null,
};
this._milestoneQueue = [];
this._milestoneShowing = false;
this._lastStatsUpdate = null;
this._statsPanelEls = null;
this._completionOverlay = null;
this._roomStatsPanel = null;
this._puzzleStatsPanel = 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();
// Update stats panel throttled to once per second
if (!this._lastStatsUpdate || Date.now() - this._lastStatsUpdate > 1000) {
this._updateStatsPanel();
this._lastStatsUpdate = Date.now();
}
// 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));
}
// Pre-compute piece ID sets for stats/milestones
this._cornerIds = this._pieces
.filter(p => [p.edges.top, p.edges.bottom, p.edges.left, p.edges.right]
.filter(e => e === 'flat').length === 2)
.map(p => p.id);
this._edgeIds = this._pieces
.filter(p => p.edges.top === 'flat' || p.edges.bottom === 'flat' ||
p.edges.left === 'flat' || p.edges.right === 'flat')
.map(p => p.id);
const midCol = Math.ceil(this._cols / 2);
const midRow = Math.ceil(this._rows / 2);
this._quadrantIds = {
UL: new Set(this._pieces.filter(p => p.gridCol < midCol && p.gridRow < midRow).map(p => p.id)),
UR: new Set(this._pieces.filter(p => p.gridCol >= midCol && p.gridRow < midRow).map(p => p.id)),
LL: new Set(this._pieces.filter(p => p.gridCol < midCol && p.gridRow >= midRow).map(p => p.id)),
LR: new Set(this._pieces.filter(p => p.gridCol >= midCol && p.gridRow >= midRow).map(p => p.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<pieceId> 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 (this._completed || this._puzzleViewMode) return;
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();
// Detect milestones already achieved on restore (no overlay)
if (saved) {
this._suppressMilestoneOverlay = true;
this._checkMilestones('local');
this._suppressMilestoneOverlay = false;
this._milestoneQueue.length = 0;
}
// Check already complete on restore
if (saved && saved.completed) {
if (this._roomStatsPanel) this._roomStatsPanel.style.display = 'none';
if (this._puzzleStatsPanel) this._puzzleStatsPanel.style.display = 'none';
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');
// Track remote snaps
const snapsFromRelease = prevCount - this._groupManager.groupCount;
this._stats.totalSnaps += snapsFromRelease;
for (let i = 0; i < snapsFromRelease; i++) {
this._stats.snapTimestamps.push(Date.now());
}
this._stats.playerSnaps.set(msg.playerId,
(this._stats.playerSnaps.get(msg.playerId) || 0) + snapsFromRelease);
this._checkMilestones(msg.playerId);
}
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();
this._updateStatsPanel();
}
_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();
this._updateStatsPanel();
}
_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
const fitZoom = Math.min(width / worldW, height / worldH);
this._minZoom = fitZoom * 0.6;
cam.setZoom(fitZoom);
// 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 (this._completed || this._puzzleViewMode) return;
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 || this._completed || this._puzzleViewMode) 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._clearPieceGlowFX();
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;
// Track snap stats
this._stats.totalSnaps++;
this._stats.snapTimestamps.push(Date.now());
const localId = (this._isNetworked && NetworkManager.playerId) ? NetworkManager.playerId : 'local';
this._stats.playerSnaps.set(localId, (this._stats.playerSnaps.get(localId) || 0) + 1);
// Update heldPiece reference so getPeersOf returns the enlarged group
// (heldPiece pointer stays the same object, groupManager now returns merged group)
}
// Check milestones after all cascade snaps
const localId = (this._isNetworked && NetworkManager.playerId) ? NetworkManager.playerId : 'local';
this._checkMilestones(localId);
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);
}
// Contour-following drop shadow via preFX (Phaser 3.60+)
if (po.image.preFX) {
if (!isMerged && !po._shadowFX) {
po._shadowFX = po.image.preFX.addShadow(2, 2, 0.06, 1, 0x000000, 6);
} else if (isMerged && po._shadowFX) {
po.image.preFX.remove(po._shadowFX);
po._shadowFX = null;
}
}
});
});
}
// ─── Glow ────────────────────────────────────────────────────────────
_lerpColor(c1, c2, t) {
const r1 = (c1 >> 16) & 0xff, g1 = (c1 >> 8) & 0xff, b1 = c1 & 0xff;
const r2 = (c2 >> 16) & 0xff, g2 = (c2 >> 8) & 0xff, b2 = c2 & 0xff;
const r = Math.round(r1 + (r2 - r1) * t);
const g = Math.round(g1 + (g2 - g1) * t);
const b = Math.round(b1 + (b2 - b1) * t);
return (r << 16) | (g << 8) | b;
}
_clearPieceGlowFX() {
if (this._glowingPieceIds) {
this._glowingPieceIds.forEach(id => {
const po = this._pieceObjects.get(id);
if (po && po.image.preFX && po._glowFX) {
po.image.preFX.remove(po._glowFX);
po._glowFX = null;
}
});
this._glowingPieceIds.clear();
}
if (this._glowTimers) this._glowTimers.clear();
}
_drawGlow() {
this._glowGraphics.clear();
if (!this._heldPiece || this._boxSelectedIds) {
this._clearPieceGlowFX();
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
);
if (!this._glowingPieceIds) this._glowingPieceIds = new Set();
if (!this._glowTimers) this._glowTimers = new Map();
const GLOW_DELAY = 500; // ms a neighbor must stay in range before glow appears
const now = Date.now();
// Track which neighbors are in proximity this frame
const activeIds = new Set();
// Color endpoints: far = cyan, close = warm gold-white
const COLOR_FAR = 0x00ccff;
const COLOR_CLOSE = 0xffffaa;
// Collect the best (highest intensity) glow per held piece
const heldGlowMap = new Map(); // heldId → { intensity, fadeIn, color }
segments.forEach(({ x0, y0, x1, y1, dist, neighborId, heldId, glowRadius: gr }) => {
activeIds.add(neighborId);
// Record when this neighbor first entered proximity
if (!this._glowTimers.has(neighborId)) {
this._glowTimers.set(neighborId, now);
}
// Skip rendering until the piece has been in proximity for GLOW_DELAY ms
const elapsed = now - this._glowTimers.get(neighborId);
if (elapsed < GLOW_DELAY) return;
// Fade in over 300ms after the delay
const fadeIn = Math.min(1, (elapsed - GLOW_DELAY) / 300);
// Intensity: 0 at edge of glow radius, 1 when pieces are perfectly aligned
const intensity = Math.max(0, Math.min(1, 1 - dist / gr)) * fadeIn;
const color = this._lerpColor(COLOR_FAR, COLOR_CLOSE, intensity);
// Edge-line glow — scale width and alpha by intensity
[
[20, 0.06],
[14, 0.12],
[8, 0.30],
[4, 0.70],
].forEach(([baseLW, baseAlpha]) => {
const lw = Math.max(1, baseLW * intensity);
const alpha = baseAlpha * intensity;
this._glowGraphics.lineStyle(lw, color, alpha);
this._glowGraphics.beginPath();
this._glowGraphics.moveTo(x0, y0);
this._glowGraphics.lineTo(x1, y1);
this._glowGraphics.strokePath();
});
// Track the strongest glow for each held piece
const prev = heldGlowMap.get(heldId);
if (!prev || intensity > prev.intensity) {
heldGlowMap.set(heldId, { intensity, color });
}
});
// Apply preFX glow to held pieces (not neighbors)
heldGlowMap.forEach(({ intensity, color }, heldId) => {
const po = this._pieceObjects.get(heldId);
if (po && po.image.preFX) {
activeIds.add(heldId);
const outerStrength = 2 + intensity * 8;
const innerStrength = intensity * 2;
if (po._glowFX) {
po._glowFX.outerStrength = outerStrength;
po._glowFX.innerStrength = innerStrength;
po._glowFX.color = color;
} else {
po._glowFX = po.image.preFX.addGlow(color, outerStrength, innerStrength, false, 0.1, 12);
this._glowingPieceIds.add(heldId);
}
}
});
// Remove FX and timers from pieces no longer in range
this._glowTimers.forEach((_, id) => {
if (!activeIds.has(id)) this._glowTimers.delete(id);
});
this._glowingPieceIds.forEach(id => {
if (!activeIds.has(id)) {
const po = this._pieceObjects.get(id);
if (po && po.image.preFX && po._glowFX) {
po.image.preFX.remove(po._glowFX);
po._glowFX = null;
}
this._glowingPieceIds.delete(id);
}
});
}
// ─── 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;
// Clear any pending milestone overlays and prevent new ones
this._milestoneQueue.length = 0;
StorageManager.clearCurrent();
// Hide stats panels
if (this._roomStatsPanel) this._roomStatsPanel.style.display = 'none';
if (this._puzzleStatsPanel) this._puzzleStatsPanel.style.display = 'none';
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');
}
// ─── Stats helpers ────────────────────────────────────────────────
_countConnectedPieces() {
let count = 0;
this._groupManager.groups.forEach(members => {
if (members.size > 1) count += members.size;
});
return count;
}
_countConnectedEdgePieces() {
if (!this._edgeIds) return 0;
const edgeSet = new Set(this._edgeIds);
let count = 0;
this._groupManager.groups.forEach(members => {
if (members.size > 1) {
members.forEach(id => { if (edgeSet.has(id)) count++; });
}
});
return count;
}
_calcSnapsPerMinute() {
const now = Date.now();
const oneMinAgo = now - 60000;
return this._stats.snapTimestamps.filter(t => t >= oneMinAgo).length;
}
_getLargestGroupSize() {
let max = 0;
this._groupManager.groups.forEach(members => {
if (members.size > max) max = members.size;
});
return max;
}
_checkQuadrantComplete(quadrantSet) {
if (!quadrantSet || quadrantSet.size === 0) return false;
const iter = quadrantSet.values();
const firstId = iter.next().value;
const groupId = this._groupManager.getGroupId(firstId);
if (groupId === null) return false;
for (const id of quadrantSet) {
if (this._groupManager.getGroupId(id) !== groupId) return false;
}
return true;
}
_checkMilestones(playerId) {
if (!this._cornerIds || !this._edgeIds || !this._quadrantIds) return;
if (this._completed) return;
const elapsed = this._startTime ? Date.now() - this._startTime : 0;
const playerName = this._getPlayerName(playerId);
let newMilestones = false;
const msData = { time: elapsed, playerName };
// Corners Found
if (!this._stats.cornersFound) {
const allFound = this._cornerIds.every(id => {
const group = this._groupManager.getGroup(id);
return group && group.size > 1;
});
if (allFound) {
this._stats.cornersFound = msData;
this._milestoneQueue.push({ title: 'Corners Found', playerName, elapsed });
newMilestones = true;
}
}
// Area Complete: Upper-Left
if (!this._stats.areaCompleteUL && this._checkQuadrantComplete(this._quadrantIds.UL)) {
this._stats.areaCompleteUL = msData;
this._milestoneQueue.push({ title: 'Area Complete: Upper-Left', playerName, elapsed });
newMilestones = true;
}
// Area Complete: Upper-Right
if (!this._stats.areaCompleteUR && this._checkQuadrantComplete(this._quadrantIds.UR)) {
this._stats.areaCompleteUR = msData;
this._milestoneQueue.push({ title: 'Area Complete: Upper-Right', playerName, elapsed });
newMilestones = true;
}
// Area Complete: Lower-Left
if (!this._stats.areaCompleteLL && this._checkQuadrantComplete(this._quadrantIds.LL)) {
this._stats.areaCompleteLL = msData;
this._milestoneQueue.push({ title: 'Area Complete: Lower-Left', playerName, elapsed });
newMilestones = true;
}
// Area Complete: Lower-Right
if (!this._stats.areaCompleteLR && this._checkQuadrantComplete(this._quadrantIds.LR)) {
this._stats.areaCompleteLR = msData;
this._milestoneQueue.push({ title: 'Area Complete: Lower-Right', playerName, elapsed });
newMilestones = true;
}
// Edge Complete
if (!this._stats.edgeComplete && this._edgeIds.length > 0) {
const firstGroup = this._groupManager.getGroupId(this._edgeIds[0]);
if (firstGroup !== null) {
const allSame = this._edgeIds.every(id => this._groupManager.getGroupId(id) === firstGroup);
if (allSame) {
this._stats.edgeComplete = msData;
this._milestoneQueue.push({ title: 'Edge Complete', playerName, elapsed });
newMilestones = true;
}
}
}
if (newMilestones) {
this._updateStatsPanel();
if (!this._suppressMilestoneOverlay) {
this._showNextMilestone();
}
}
}
_getPlayerName(playerId) {
if (playerId === 'local') return null;
if (!this._isNetworked) return null;
if (playerId === NetworkManager.playerId) {
return this.cfg.playerName || 'You';
}
return this._playerNames.get(playerId) || ('Player ' + playerId);
}
_getPlayerColor(playerId) {
if (playerId === 'local' || !this._isNetworked) return '#64b5f6';
return '#' + getPlayerColor(playerId).toString(16).padStart(6, '0');
}
_formatElapsed(elapsedMs) {
const totalSec = Math.floor(elapsedMs / 1000);
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return String(m).padStart(2, '0') + ':' + String(s).padStart(2, '0');
}
// ─── Milestone celebration overlay ────────────────────────────────
_showNextMilestone() {
if (this._completed || this._milestoneShowing || this._milestoneQueue.length === 0) return;
this._milestoneShowing = true;
const milestone = this._milestoneQueue.shift();
const overlay = document.createElement('div');
Object.assign(overlay.style, {
position: 'fixed',
top: '0',
left: '0',
width: '100vw',
height: '100vh',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: '50',
opacity: '0',
transform: 'scale(0.8)',
transition: 'opacity 0.4s ease-out, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
});
const accentColor = milestone.playerName ? this._getPlayerColor(
this._getPlayerIdByName(milestone.playerName)
) : '#64b5f6';
// Title
const titleEl = document.createElement('div');
Object.assign(titleEl.style, {
fontSize: '52px',
fontFamily: 'Georgia, serif',
fontWeight: 'bold',
color: '#ffffff',
textShadow: `0 0 30px ${accentColor}, 0 0 60px ${accentColor}, 0 4px 8px rgba(0,0,0,0.6)`,
textAlign: 'center',
marginBottom: '12px',
});
titleEl.textContent = milestone.title;
// Timestamp
const timeEl = document.createElement('div');
Object.assign(timeEl.style, {
fontSize: '24px',
fontFamily: 'monospace',
color: '#aaccff',
textShadow: '0 2px 6px rgba(0,0,0,0.5)',
marginBottom: '8px',
});
timeEl.textContent = this._formatElapsed(milestone.elapsed);
// Player attribution (multiplayer only)
if (milestone.playerName) {
const playerEl = document.createElement('div');
Object.assign(playerEl.style, {
fontSize: '28px',
fontFamily: 'Arial, sans-serif',
color: accentColor,
textShadow: '0 2px 6px rgba(0,0,0,0.5)',
});
playerEl.textContent = milestone.playerName;
overlay.appendChild(titleEl);
overlay.appendChild(timeEl);
overlay.appendChild(playerEl);
} else {
overlay.appendChild(titleEl);
overlay.appendChild(timeEl);
}
// Backdrop glow
const backdrop = document.createElement('div');
Object.assign(backdrop.style, {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '700px',
height: '250px',
background: 'radial-gradient(ellipse, rgba(0,0,0,0.6) 0%, transparent 70%)',
borderRadius: '50%',
zIndex: '-1',
});
overlay.appendChild(backdrop);
document.body.appendChild(overlay);
// Phase 1: Fly in (0.4s)
requestAnimationFrame(() => {
overlay.style.opacity = '1';
overlay.style.transform = 'scale(1)';
});
// Phase 2: Hold (~4s), then Phase 3: Fly off (0.6s)
setTimeout(() => {
Object.assign(overlay.style, {
transition: 'opacity 0.6s ease-in, transform 0.6s ease-in',
opacity: '0',
transform: 'translateX(120vw) rotate(8deg) scale(0.9)',
});
setTimeout(() => {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
this._milestoneShowing = false;
this._showNextMilestone();
}, 650);
}, 4400);
}
_getPlayerIdByName(name) {
if (name === this.cfg.playerName || name === 'You') {
return NetworkManager.playerId || 'local';
}
for (const [id, n] of this._playerNames) {
if (n === name) return id;
}
return 'local';
}
// ─── Puzzle Stats panel (right side) ──────────────────────────────
_buildStatsPanel() {
this._puzzleStatsPanel = document.createElement('div');
const panel = this._puzzleStatsPanel;
Object.assign(panel.style, {
position: 'absolute',
top: '7.5%',
right: '0.5%',
background: 'rgba(0, 0, 0, 0.55)',
padding: '10px 16px',
borderRadius: '4px',
fontFamily: 'Arial, sans-serif',
color: '#ddeeff',
fontSize: '13px',
lineHeight: '1.6',
overflow: 'hidden',
transition: 'width 0.3s ease, padding 0.3s ease',
width: 'auto',
maxWidth: '240px',
pointerEvents: 'auto',
});
// Header row with collapse toggle
const header = document.createElement('div');
Object.assign(header.style, {
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
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 title = document.createElement('span');
Object.assign(title.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',
});
title.textContent = 'Puzzle Stats';
header.appendChild(toggleIcon);
header.appendChild(title);
panel.appendChild(header);
// Collapsible content wrapper
const content = document.createElement('div');
Object.assign(content.style, {
overflow: 'hidden',
transition: 'max-height 0.3s ease, opacity 0.25s ease',
opacity: '1',
});
const labelStyle = { color: '#8899bb', fontSize: '11px', marginTop: '6px' };
const valueStyle = { color: '#ddeeff', fontSize: '13px' };
const milestoneStyle = { color: '#ddeeff', fontSize: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px' };
const milestoneTimeStyle = { fontFamily: 'monospace', color: '#8899bb' };
// --- Universal Stats ---
const makeStatRow = (labelText) => {
const label = document.createElement('div');
Object.assign(label.style, labelStyle);
label.textContent = labelText;
content.appendChild(label);
const val = document.createElement('div');
Object.assign(val.style, valueStyle);
val.textContent = '--';
content.appendChild(val);
return val;
};
const piecesEl = makeStatRow('Pieces Connected');
const edgesEl = makeStatRow('Edge Progress');
const snapsEl = makeStatRow('Total Snaps');
const paceEl = makeStatRow('Pace');
const largestEl = makeStatRow('Largest Group');
// --- Milestones (dynamically rebuilt in chronological order) ---
const msLabel = document.createElement('div');
Object.assign(msLabel.style, { color: '#8899bb', fontSize: '11px', marginTop: '10px', borderTop: '1px solid rgba(136,153,187,0.2)', paddingTop: '6px' });
msLabel.textContent = 'Milestones';
content.appendChild(msLabel);
const msContainer = document.createElement('div');
content.appendChild(msContainer);
// --- Multiplayer section ---
const mpSection = document.createElement('div');
Object.assign(mpSection.style, { display: 'none' });
const mpDivider = document.createElement('div');
Object.assign(mpDivider.style, { borderTop: '1px solid rgba(136,153,187,0.3)', marginTop: '10px', paddingTop: '6px' });
const mpHeaderRow = document.createElement('div');
Object.assign(mpHeaderRow.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center' });
const mpLabel = document.createElement('span');
Object.assign(mpLabel.style, { color: '#8899bb', fontSize: '11px' });
mpLabel.textContent = 'Player Stats';
const mpToggle = document.createElement('span');
const mpHidden = localStorage.getItem('ipuzzle_hidePlayerStats') === 'true';
Object.assign(mpToggle.style, {
color: '#8899bb',
fontSize: '11px',
cursor: 'pointer',
userSelect: 'none',
});
mpToggle.textContent = mpHidden ? 'Show' : 'Hide';
mpToggle.addEventListener('mouseenter', () => { mpToggle.style.color = '#ddeeff'; });
mpToggle.addEventListener('mouseleave', () => { mpToggle.style.color = '#8899bb'; });
mpHeaderRow.appendChild(mpLabel);
mpHeaderRow.appendChild(mpToggle);
mpDivider.appendChild(mpHeaderRow);
mpSection.appendChild(mpDivider);
const mpContent = document.createElement('div');
Object.assign(mpContent.style, { display: mpHidden ? 'none' : 'block', marginTop: '4px' });
mpSection.appendChild(mpContent);
mpToggle.addEventListener('click', (e) => {
e.stopPropagation();
const hide = mpContent.style.display !== 'none';
mpContent.style.display = hide ? 'none' : 'block';
localStorage.setItem('ipuzzle_hidePlayerStats', hide ? 'true' : 'false');
mpToggle.textContent = hide ? 'Show' : 'Hide';
});
content.appendChild(mpSection);
panel.appendChild(content);
// Measure natural height after first render
requestAnimationFrame(() => {
content.style.maxHeight = content.scrollHeight + 'px';
});
// Three-state collapse machine (same pattern as Room Stats)
let state = 'expanded';
let animating = false;
const collapseContent = () => {
animating = true;
content.style.maxHeight = content.scrollHeight + 'px';
content.offsetHeight; // force reflow
content.style.maxHeight = '0px';
content.style.opacity = '0';
toggleIcon.textContent = '\u25BC';
content.addEventListener('transitionend', function onFolded(ev) {
if (ev.propertyName !== 'max-height') return;
content.removeEventListener('transitionend', onFolded);
state = 'folded';
collapseTitle();
});
};
const collapseTitle = () => {
const currentW = panel.offsetWidth;
panel.style.width = currentW + 'px';
panel.offsetHeight; // force reflow
title.style.maxWidth = '0px';
title.style.opacity = '0';
title.style.marginLeft = '0px';
panel.style.width = '30px';
panel.style.padding = '8px';
title.addEventListener('transitionend', function onDone(ev) {
if (ev.propertyName !== 'max-width') return;
title.removeEventListener('transitionend', onDone);
toggleIcon.textContent = '\u25B6';
state = 'collapsed';
animating = false;
});
};
const expandTitle = () => {
animating = true;
toggleIcon.textContent = '\u25BC';
panel.style.padding = '10px 16px';
panel.style.width = 'auto';
const fullW = panel.offsetWidth;
panel.style.width = '30px';
panel.offsetHeight; // force reflow
panel.style.width = fullW + 'px';
title.style.maxWidth = '200px';
title.style.opacity = '1';
title.style.marginLeft = '8px';
title.addEventListener('transitionend', function onBack(ev) {
if (ev.propertyName !== 'max-width') return;
title.removeEventListener('transitionend', onBack);
panel.style.width = 'auto';
state = 'folded';
expandContentFn();
});
};
const expandContentFn = () => {
content.style.transition = 'none';
content.style.maxHeight = 'none';
const fullHeight = content.scrollHeight;
content.style.maxHeight = '0px';
content.offsetHeight; // force reflow
content.style.transition = 'max-height 0.3s ease, opacity 0.25s ease';
content.style.maxHeight = fullHeight + 'px';
content.style.opacity = '1';
toggleIcon.textContent = '\u25B2';
content.addEventListener('transitionend', function onExp(ev) {
if (ev.propertyName !== 'max-height') return;
content.removeEventListener('transitionend', onExp);
content.style.maxHeight = 'none';
state = 'expanded';
animating = false;
});
};
header.addEventListener('click', (e) => {
e.stopPropagation();
if (animating) return;
if (state === 'expanded') collapseContent();
else if (state === 'collapsed') expandTitle();
});
header.addEventListener('mouseenter', () => { toggleIcon.style.color = '#ddeeff'; });
header.addEventListener('mouseleave', () => { toggleIcon.style.color = '#8899bb'; });
this._uiLayer.appendChild(panel);
// Store references for updates
this._statsPanelEls = {
piecesEl, edgesEl, snapsEl, paceEl, largestEl,
msContainer, milestoneStyle, milestoneTimeStyle,
mpSection, mpContent, content,
};
// Initial update
this._updateStatsPanel();
}
_updateStatsPanel() {
const els = this._statsPanelEls;
if (!els) return;
const total = this._pieces ? this._pieces.length : 0;
const connected = this._countConnectedPieces();
const pct = total > 0 ? Math.round((connected / total) * 100) : 0;
els.piecesEl.textContent = `${connected} / ${total} (${pct}%)`;
const totalEdges = this._edgeIds ? this._edgeIds.length : 0;
const connEdges = this._countConnectedEdgePieces();
els.edgesEl.textContent = `${connEdges} / ${totalEdges}`;
els.snapsEl.textContent = String(this._stats.totalSnaps);
els.paceEl.textContent = `${this._calcSnapsPerMinute()} / min`;
els.largestEl.textContent = `${this._getLargestGroupSize()} pieces`;
// Milestones — rebuild in chronological order
const hasMP = this._playerNames.size > 0;
const allMs = [
{ name: 'Corners Found', data: this._stats.cornersFound },
{ name: 'Area: Upper-Left', data: this._stats.areaCompleteUL },
{ name: 'Area: Upper-Right', data: this._stats.areaCompleteUR },
{ name: 'Area: Lower-Left', data: this._stats.areaCompleteLL },
{ name: 'Area: Lower-Right', data: this._stats.areaCompleteLR },
{ name: 'Edge Complete', data: this._stats.edgeComplete },
];
// Sort: achieved milestones first (by time), then unachieved
allMs.sort((a, b) => {
if (a.data && b.data) return a.data.time - b.data.time;
if (a.data) return -1;
if (b.data) return 1;
return 0;
});
els.msContainer.innerHTML = '';
allMs.forEach(ms => {
const row = document.createElement('div');
Object.assign(row.style, els.milestoneStyle);
const nameEl = document.createElement('span');
nameEl.textContent = ms.name;
const rightSide = document.createElement('span');
Object.assign(rightSide.style, { display: 'flex', alignItems: 'center', gap: '4px' });
if (ms.data) {
if (hasMP && ms.data.playerName) {
const playerEl = document.createElement('span');
Object.assign(playerEl.style, { fontSize: '10px', color: '#8899bb' });
playerEl.textContent = ms.data.playerName;
rightSide.appendChild(playerEl);
}
const timeEl = document.createElement('span');
Object.assign(timeEl.style, els.milestoneTimeStyle);
timeEl.textContent = this._formatElapsed(ms.data.time);
rightSide.appendChild(timeEl);
} else {
const timeEl = document.createElement('span');
Object.assign(timeEl.style, els.milestoneTimeStyle);
timeEl.textContent = '--:--';
rightSide.appendChild(timeEl);
}
row.appendChild(nameEl);
row.appendChild(rightSide);
els.msContainer.appendChild(row);
});
// Multiplayer section visibility
const hasMultiplayer = this._playerNames.size > 0;
els.mpSection.style.display = hasMultiplayer ? 'block' : 'none';
if (hasMultiplayer) {
// Rebuild player rows
els.mpContent.innerHTML = '';
// Local player first
const localId = (this._isNetworked && NetworkManager.playerId) ? NetworkManager.playerId : 'local';
const localName = this.cfg.playerName || 'You';
const localCount = this._stats.playerSnaps.get(localId) || 0;
this._appendPlayerStatRow(els.mpContent, localName + ' (you)', localId, localCount);
// Remote players
this._playerNames.forEach((name, pid) => {
const count = this._stats.playerSnaps.get(pid) || 0;
this._appendPlayerStatRow(els.mpContent, name, pid, count);
});
}
// Update content max-height if expanded so new content doesn't get clipped
if (els.content.style.maxHeight !== 'none' && els.content.style.maxHeight !== '0px') {
els.content.style.maxHeight = 'none';
}
}
_appendPlayerStatRow(container, name, playerId, count) {
const row = document.createElement('div');
Object.assign(row.style, {
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
lineHeight: '1.8',
});
const dot = document.createElement('span');
Object.assign(dot.style, {
width: '8px',
height: '8px',
borderRadius: '50%',
background: this._getPlayerColor(playerId),
flexShrink: '0',
});
const nameEl = document.createElement('span');
Object.assign(nameEl.style, { color: '#ddeeff', flex: '1', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' });
nameEl.textContent = name;
const countEl = document.createElement('span');
Object.assign(countEl.style, { color: '#8899bb', fontFamily: 'monospace', flexShrink: '0' });
countEl.textContent = count + (count === 1 ? ' piece' : ' pieces');
row.appendChild(dot);
row.appendChild(nameEl);
row.appendChild(countEl);
container.appendChild(row);
}
_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 ──────────────────────────────────────────────────────────
_syncOverlayToCanvas() {
if (!this._uiLayer) return;
const rect = this.sys.game.canvas.getBoundingClientRect();
Object.assign(this._uiLayer.style, {
left: rect.left + 'px',
top: rect.top + 'px',
transform: `scale(${rect.width / 1920}, ${rect.height / 1080})`,
});
}
_buildDomUI() {
// Overlay div that mirrors the canvas 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',
width: '1920px',
height: '1080px',
transformOrigin: 'top left',
pointerEvents: 'none',
zIndex: '10',
fontFamily: 'Arial, sans-serif',
});
document.body.appendChild(this._uiLayer);
this._syncOverlayToCanvas();
// Keep overlay aligned when the window resizes
this._onResizeScale = () => this._syncOverlayToCanvas();
this.scale.on('resize', this._onResizeScale);
window.addEventListener('resize', this._onResizeScale);
// 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)
this._roomStatsPanel = document.createElement('div');
const statsPanel = this._roomStatsPanel;
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);
// ─── Puzzle Stats panel — right side, below music ───────────────
this._buildStatsPanel();
// Clean up DOM when the scene shuts down
this.events.once('shutdown', () => {
if (this._onResizeScale) {
this.scale.off('resize', this._onResizeScale);
window.removeEventListener('resize', this._onResizeScale);
}
this._destroyDomUI();
});
}
_showDomCompletion() {
// Full-screen overlay — blocks clicks from reaching the canvas
this._completionOverlay = document.createElement('div');
const overlay = this._completionOverlay;
Object.assign(overlay.style, {
position: 'absolute',
inset: '0',
background: 'rgba(0,0,0,0.55)',
pointerEvents: 'auto',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'flex-start',
paddingTop: '6%',
overflow: 'hidden',
});
// Title
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',
marginBottom: '1vmin',
});
title.textContent = 'Puzzle Complete!';
// Total time
const timeLabel = document.createElement('div');
Object.assign(timeLabel.style, {
color: '#aaccff',
fontSize: '2.5vmin',
fontFamily: 'Arial, sans-serif',
marginBottom: '3vmin',
});
const totalSec = Math.floor((this._completionTime || 0) / 1000);
timeLabel.textContent = `Total Puzzle Time: ${this._formatTime(totalSec)}`;
// Typewriter stats area
const statsArea = document.createElement('div');
Object.assign(statsArea.style, {
width: '50%',
maxWidth: '700px',
minHeight: '30vmin',
maxHeight: '45vmin',
overflowY: 'auto',
background: 'rgba(0, 8, 20, 0.7)',
border: '1px solid rgba(68, 102, 170, 0.4)',
borderRadius: '6px',
padding: '2vmin 2.5vmin',
marginBottom: '3vmin',
fontFamily: 'monospace',
fontSize: '1.5vmin',
color: '#44cc88',
lineHeight: '2',
});
// Button row
const btnRow = document.createElement('div');
Object.assign(btnRow.style, {
display: 'flex',
gap: '2vmin',
});
const showBtn = this._makeDomBtn('Show Puzzle', () => this._showPuzzleView());
const newBtn = this._makeDomBtn('New Puzzle', () => this.scene.start('NewPuzzleScene'));
const menuBtn = this._makeDomBtn('Main Menu', () => this.scene.start('MainMenuScene'));
[showBtn, newBtn, menuBtn].forEach(b => { b.style.position = 'static'; });
btnRow.appendChild(showBtn);
btnRow.appendChild(newBtn);
btnRow.appendChild(menuBtn);
overlay.appendChild(title);
overlay.appendChild(timeLabel);
overlay.appendChild(statsArea);
overlay.appendChild(btnRow);
this._uiLayer.appendChild(overlay);
// Build typewriter lines
this._typewriterStats(statsArea);
}
_buildCompletionLines() {
const lines = [];
const totalSec = Math.floor((this._completionTime || 0) / 1000);
lines.push({ text: '> PUZZLE SESSION REPORT', color: '#aaccff' });
lines.push({ text: ` Pieces: ${this._pieces.length} | Total Time: ${this._formatTime(totalSec)}`, color: '#ddeeff' });
lines.push({ text: '' });
// Stats summary
lines.push({ text: '> STATISTICS', color: '#aaccff' });
lines.push({ text: ` Total Snaps ............ ${this._stats.totalSnaps}`, color: '#ddeeff' });
lines.push({ text: ` Largest Group .......... ${this._getLargestGroupSize()} pieces`, color: '#ddeeff' });
const edgeTotal = this._edgeIds ? this._edgeIds.length : 0;
lines.push({ text: ` Edge Pieces ............ ${edgeTotal}`, color: '#ddeeff' });
lines.push({ text: '' });
// Milestones
lines.push({ text: '> MILESTONES', color: '#aaccff' });
const milestones = [
{ name: 'Corners Found', data: this._stats.cornersFound },
{ name: 'Area: Upper-Left', data: this._stats.areaCompleteUL },
{ name: 'Area: Upper-Right', data: this._stats.areaCompleteUR },
{ name: 'Area: Lower-Left', data: this._stats.areaCompleteLL },
{ name: 'Area: Lower-Right', data: this._stats.areaCompleteLR },
{ name: 'Edge Complete', data: this._stats.edgeComplete },
];
// Sort chronologically: achieved first by time, then unachieved
milestones.sort((a, b) => {
if (a.data && b.data) return a.data.time - b.data.time;
if (a.data) return -1;
if (b.data) return 1;
return 0;
});
const hasMP = this._playerNames.size > 0;
milestones.forEach(ms => {
const achieved = ms.data !== null;
const timeStr = achieved ? this._formatElapsed(ms.data.time) : '--:--';
const byPlayer = (achieved && hasMP && ms.data.playerName) ? ` [${ms.data.playerName}]` : '';
const pad = '.'.repeat(Math.max(1, 28 - ms.name.length));
lines.push({ text: ` ${ms.name} ${pad} ${timeStr}${byPlayer}`, color: achieved ? '#44cc88' : '#666688' });
});
// Multiplayer player stats
if (this._playerNames.size > 0) {
lines.push({ text: '' });
lines.push({ text: '> PLAYER CONTRIBUTIONS', color: '#aaccff' });
const localId = (this._isNetworked && NetworkManager.playerId) ? NetworkManager.playerId : 'local';
const localName = this.cfg.playerName || 'You';
const localCount = this._stats.playerSnaps.get(localId) || 0;
const allPlayers = [{ name: localName + ' (you)', count: localCount }];
this._playerNames.forEach((name, pid) => {
allPlayers.push({ name, count: this._stats.playerSnaps.get(pid) || 0 });
});
// Sort by count descending
allPlayers.sort((a, b) => b.count - a.count);
allPlayers.forEach((p, i) => {
const rank = i === 0 ? '*' : ' ';
const pad = '.'.repeat(Math.max(1, 26 - p.name.length));
lines.push({ text: ` ${rank} ${p.name} ${pad} ${p.count} pieces`, color: '#ddeeff' });
});
}
lines.push({ text: '' });
lines.push({ text: '> SESSION COMPLETE', color: '#44cc88' });
return lines;
}
_typewriterStats(container) {
const lines = this._buildCompletionLines();
let lineIdx = 0;
let charIdx = 0;
let currentEl = null;
const cursorEl = document.createElement('span');
Object.assign(cursorEl.style, {
color: '#44cc88',
animation: 'none',
});
cursorEl.textContent = '\u2588'; // block cursor
// Blink cursor via manual toggle
let cursorVisible = true;
const cursorBlink = setInterval(() => {
cursorVisible = !cursorVisible;
cursorEl.style.opacity = cursorVisible ? '1' : '0';
}, 530);
const typeNext = () => {
if (this._completionOverlay === null) {
clearInterval(cursorBlink);
return;
}
if (lineIdx >= lines.length) {
// Done — keep cursor blinking at end
return;
}
const line = lines[lineIdx];
if (charIdx === 0) {
// Start new line
currentEl = document.createElement('div');
Object.assign(currentEl.style, {
color: line.color || '#44cc88',
minHeight: '1.2em',
whiteSpace: 'pre',
});
container.appendChild(currentEl);
}
if (charIdx < line.text.length) {
currentEl.textContent = line.text.substring(0, charIdx + 1);
// Keep cursor at end
if (cursorEl.parentNode) cursorEl.parentNode.removeChild(cursorEl);
currentEl.appendChild(cursorEl);
charIdx++;
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
// Speed: headers are faster, dots are very fast
const ch = line.text[charIdx - 1];
const delay = ch === '.' ? 8 : (line.color === '#aaccff' ? 20 : 30);
setTimeout(typeNext, delay);
} else {
// Line complete — move cursor to next line
if (cursorEl.parentNode) cursorEl.parentNode.removeChild(cursorEl);
lineIdx++;
charIdx = 0;
// Pause between lines
const pause = line.text === '' ? 100 : (line.color === '#aaccff' ? 300 : 150);
setTimeout(typeNext, pause);
}
};
// Start typing after a short delay
setTimeout(typeNext, 500);
}
_showPuzzleView() {
if (!this._completionOverlay) return;
this._puzzleViewMode = true;
this._completionOverlay.style.display = 'none';
// Hide other UI elements
const uiChildren = this._uiLayer.children;
this._hiddenUiEls = [];
for (let i = 0; i < uiChildren.length; i++) {
const el = uiChildren[i];
if (el !== this._completionOverlay && el.style.display !== 'none') {
this._hiddenUiEls.push(el);
el.style.display = 'none';
}
}
// Show a "Back" button
this._showPuzzleBackBtn = this._makeDomBtn('Back', () => this._hidePuzzleView());
Object.assign(this._showPuzzleBackBtn.style, {
position: 'absolute',
top: '10px',
left: '10px',
zIndex: '20',
});
this._uiLayer.appendChild(this._showPuzzleBackBtn);
}
_hidePuzzleView() {
this._puzzleViewMode = false;
// Remove back button
if (this._showPuzzleBackBtn && this._showPuzzleBackBtn.parentNode) {
this._showPuzzleBackBtn.parentNode.removeChild(this._showPuzzleBackBtn);
this._showPuzzleBackBtn = null;
}
// Restore hidden UI elements
if (this._hiddenUiEls) {
this._hiddenUiEls.forEach(el => { el.style.display = ''; });
this._hiddenUiEls = null;
}
// Show completion overlay again
if (this._completionOverlay) {
this._completionOverlay.style.display = 'flex';
}
}
_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 emitter = this.add.particles(0, 0, key, {
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
});
emitter.setDepth(1003);
this.time.delayedCall(2500, () => { emitter.stop(); });
});
}
// ─── 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;
}
}