iPuzzle/js/scenes/PuzzleScene.js

1023 lines
34 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)
class PuzzleScene extends Phaser.Scene {
constructor() {
super({ key: 'PuzzleScene' });
}
// ─── Phaser lifecycle ────────────────────────────────────────────────
init(data) {
this.cfg = data || {};
this._isNetworked = false;
this._isHost = false;
this._remoteClaims = new Map(); // pieceId -> playerId (pieces held by others)
// 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() {
// 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);
}
}
}
}
create() {
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;
// Camera pan (right-click drag, or left-click drag on empty space)
const ptr = this.input.activePointer;
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) {
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;
// 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 });
// DOM UI (immune to camera zoom)
this._buildDomUI();
// 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;
this._setupNetworkListeners();
} 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,
});
NetworkManager.createRoom(this.cfg.roomCode, state.serialize());
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;
const pieceId = msg.pieceId;
// Find all pieces in the same group and tint them
const groupId = this._groupManager.getGroupId(pieceId);
if (groupId === null) return;
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(0xaaaaff); // light blue tint
});
}
_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);
});
}
_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) {
this._groupManager.rebuildFromGroups(msg.groups);
}
// 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) {
console.log(`Player ${msg.playerId} joined the room`);
}
_onNetworkPlayerLeft(msg) {
// 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();
}
}
console.log(`Player ${msg.playerId} left the room`);
}
_onNetworkCompleted() {
if (!this._completed) {
this._completed = true;
this._showCompletion(true);
}
}
_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;
// 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() {
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;
}
// ─── 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;
// 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 (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;
this._saveState();
this._checkCompletion();
}
_moveHeldGroup(dx, dy) {
const peers = this._groupManager.getPeersOf(this._heldPiece.data.id);
peers.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);
snapped = true;
// Update heldPiece reference so getPeersOf returns the enlarged group
// (heldPiece pointer stays the same object, groupManager now returns merged group)
}
}
// ─── Glow ────────────────────────────────────────────────────────────
_drawGlow() {
this._glowGraphics.clear();
if (!this._heldPiece) 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 }) => {
// Three passes: outer, mid, inner glow
[[10, 0x00ccff, 0.10], [6, 0x00eeff, 0.28], [3, 0xaaffff, 0.65]].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;
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());
}
// ─── 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 — bottom-right
const roomEl = document.createElement('div');
Object.assign(roomEl.style, {
position: 'absolute',
bottom: '1.5%',
right: '1%',
color: '#ddeeff',
fontSize: '2.4vmin',
fontFamily: 'monospace',
fontWeight: 'bold',
letterSpacing:'0.15em',
background: 'rgba(0, 0, 0, 0.55)',
padding: '0.6vmin 1.2vmin',
borderRadius: '4px',
});
roomEl.textContent = `Room: ${this.cfg.roomCode}`;
this._uiLayer.appendChild(roomEl);
// 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: '1%', left: '0.5%' });
this._uiLayer.appendChild(backBtn);
// 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 roomLabel = document.createElement('div');
Object.assign(roomLabel.style, {
color: '#aaccff',
fontSize: '2.2vmin',
});
roomLabel.textContent = `Room: ${this.cfg.roomCode}`;
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(roomLabel);
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: '#1a2a4a',
color: '#ddeeff',
border: '1px solid #4477bb',
borderRadius: '4px',
fontSize: '1.6vmin',
fontFamily: 'Arial, sans-serif',
cursor: 'pointer',
pointerEvents: 'auto',
whiteSpace: 'nowrap',
});
// Hover effect
btn.addEventListener('mouseenter', () => { btn.style.background = '#223355'; btn.style.borderColor = '#66aaff'; });
btn.addEventListener('mouseleave', () => { btn.style.background = '#1a2a4a'; btn.style.borderColor = '#4477bb'; });
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,
});
StorageManager.save(state);
}
// ─── 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;
}
}