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