2905 lines
96 KiB
JavaScript
2905 lines
96 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 (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
|
|
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;
|
|
|
|
// 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);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── 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;
|
|
// 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 ──────────────────────────────────────────────────────────
|
|
|
|
_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)
|
|
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', () => 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._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() {
|
|
// 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 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;
|
|
}
|
|
}
|