746 lines
25 KiB
JavaScript
746 lines
25 KiB
JavaScript
/* global Phaser, PuzzleGenerator, PieceRenderer, PieceObject, GroupManager,
|
|
SnapDetector, PuzzleState, StorageManager, getRoomCodeFromURL */
|
|
|
|
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 || {};
|
|
// 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() {
|
|
// 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);
|
|
|
|
const saved = this.cfg._restore ? StorageManager.load(this.cfg.roomCode) : null;
|
|
|
|
// 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();
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Scene construction ──────────────────────────────────────────────
|
|
|
|
_buildScene(saved) {
|
|
const { width, height } = this.sys.game.config;
|
|
|
|
// 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
|
|
const worldBounds = this._calcWorldBounds();
|
|
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);
|
|
}
|
|
}
|
|
|
|
_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);
|
|
this._justPickedUp = true; // suppress release on the coming pointerup
|
|
} 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 = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
_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) {
|
|
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++;
|
|
});
|
|
}
|
|
|
|
_release(trySnap) {
|
|
if (!this._heldPiece) return;
|
|
|
|
if (trySnap) {
|
|
this._doSnap();
|
|
}
|
|
|
|
// Reset depth
|
|
const peers = this._groupManager.getPeersOf(this._heldPiece.data.id);
|
|
peers.forEach(id => this._pieceObjects.get(id).setDepth(0));
|
|
|
|
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%',
|
|
right: '0.8%',
|
|
color: '#445577',
|
|
fontSize: '1.3vmin',
|
|
fontFamily: 'monospace',
|
|
});
|
|
roomEl.textContent = `Room: ${this.cfg.roomCode}`;
|
|
this._uiLayer.appendChild(roomEl);
|
|
|
|
// 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
|
|
});
|
|
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;
|
|
}
|
|
}
|