1203 lines
52 KiB
JavaScript
1203 lines
52 KiB
JavaScript
import * as Phaser from 'phaser';
|
||
import { COLORS, GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||
import { Button } from '../../ui/Button.js';
|
||
import { MusicPlayer } from '../../ui/MusicPlayer.js';
|
||
import { SFX, playSound } from '../../ui/Sounds.js';
|
||
import { getGameSoundtrack } from '../../services/soundtrack.js';
|
||
import {
|
||
DIFFICULTIES, DIFFICULTY_ORDER,
|
||
makeJigsaw, cellOutline, tracePath, mulberry32, cellNeighbours, resolveDrop,
|
||
} from './JigsawLogic.js';
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Layout (world == the 1920×1080 canvas). Pieces live on a big "table"; the
|
||
// board target is a fixed square. Drag pieces to their slots; drag empty space
|
||
// to pan; scroll / +/- to zoom.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const HUD_H = 88;
|
||
const BOARD_SIZE = 700;
|
||
// The puzzle plays on a field larger than the on-screen canvas. The camera pans
|
||
// over this whole field (and the player can zoom out until it all fits), so
|
||
// there's lots of room to spread pieces and explore.
|
||
const WORLD_W = 3840;
|
||
const WORLD_H = 2160;
|
||
const BOARD = { x: (WORLD_W - BOARD_SIZE) / 2, y: (WORLD_H - BOARD_SIZE) / 2, size: BOARD_SIZE };
|
||
const MAX_ZOOM = 4;
|
||
const MIN_ZOOM = 0.5; // zooms out far enough to see the entire field at once
|
||
const SNAP_FRAC = 0.42; // snap radius (×cell) — used for both locking a piece
|
||
// into the board AND joining two pieces on the table
|
||
const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Playfield: a stitched beige mat. The field renders as one piece of fabric
|
||
// (border band + dashed stitching + subtle weave) and the board target sits in
|
||
// a slightly deeper stitched "pocket", like a puzzle mat laid out for a jigsaw.
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
const MAT = {
|
||
base: '#d7ceb9', // main surface — warm, neutral beige
|
||
band: '#b4a487', // border band around the field edge
|
||
bandEdge: '#998867', // outermost rim of the mat
|
||
stitch: '#8a7857', // stitching thread
|
||
pocket: '#c7bca6', // board pocket (a shade deeper than the surface)
|
||
};
|
||
const BAND = 26; // border band width
|
||
const MAT_OVERSCAN = 140; // mat drawn past the world's bottom edge (covers the
|
||
// camera dead-zone below the world at minimum zoom;
|
||
// see clampCamera: the world top is reserved for HUD)
|
||
const POCKET_MARGIN = 28; // pocket extends this far past the board edge
|
||
|
||
function roundRectPath(ctx, x, y, w, h, r) {
|
||
r = Math.min(r, w / 2, h / 2);
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + r, y);
|
||
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
ctx.arcTo(x, y + h, x, y, r);
|
||
ctx.arcTo(x, y, x + w, y, r);
|
||
ctx.closePath();
|
||
}
|
||
|
||
// Dashed "stitching" around a rounded rect. Drawn twice — a soft shadow pass
|
||
// under the thread — so it reads as sewn into the fabric.
|
||
function stitchRect(ctx, x, y, w, h, r, inset, dash, width) {
|
||
const sx = x + inset, sy = y + inset, sw = w - 2 * inset, sh = h - 2 * inset;
|
||
const rr = Math.max(3, r - inset);
|
||
ctx.save();
|
||
ctx.lineJoin = 'round';
|
||
ctx.lineWidth = width;
|
||
ctx.setLineDash(dash);
|
||
ctx.strokeStyle = 'rgba(66,54,34,0.35)';
|
||
roundRectPath(ctx, sx, sy + 1.4, sw, sh, rr); ctx.stroke();
|
||
ctx.strokeStyle = MAT.stitch;
|
||
roundRectPath(ctx, sx, sy, sw, sh, rr); ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// Subtle fabric-weave tile: fine thread lines + a sprinkle of speckle.
|
||
function makeWeaveTile(seed) {
|
||
const S = 128;
|
||
const cv = document.createElement('canvas');
|
||
cv.width = cv.height = S;
|
||
const ctx = cv.getContext('2d');
|
||
const g = mulberry32(seed);
|
||
ctx.fillStyle = 'rgba(94,80,56,0.03)';
|
||
for (let y = 0; y < S; y += 4) ctx.fillRect(0, y, S, 1);
|
||
ctx.fillStyle = 'rgba(255,255,250,0.03)';
|
||
for (let x = 0; x < S; x += 4) ctx.fillRect(x, 0, 1, S);
|
||
for (let i = 0; i < 700; i++) {
|
||
ctx.fillStyle = g() > 0.5 ? 'rgba(94,80,56,0.06)' : 'rgba(255,255,250,0.07)';
|
||
ctx.fillRect(g() * S, g() * S, 1 + g() * 1.5, 1 + g() * 1.5);
|
||
}
|
||
return cv;
|
||
}
|
||
|
||
// Full-field mat: border band, stitching, weave and a soft vignette.
|
||
function makeMatTexture() {
|
||
const W = WORLD_W, H = WORLD_H + MAT_OVERSCAN;
|
||
const cv = document.createElement('canvas');
|
||
cv.width = W; cv.height = H;
|
||
const ctx = cv.getContext('2d');
|
||
ctx.fillStyle = MAT.band;
|
||
ctx.fillRect(0, 0, W, H);
|
||
ctx.save();
|
||
roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22);
|
||
ctx.fillStyle = MAT.base;
|
||
ctx.fill();
|
||
ctx.clip();
|
||
ctx.fillStyle = ctx.createPattern(makeWeaveTile(1234), 'repeat');
|
||
ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND);
|
||
// Soft vignette centred on the visible field (adds gentle depth).
|
||
const vg = ctx.createRadialGradient(W / 2, WORLD_H / 2, Math.min(W, WORLD_H) * 0.30, W / 2, WORLD_H / 2, Math.max(W, WORLD_H) * 0.72);
|
||
vg.addColorStop(0, 'rgba(118,102,72,0)');
|
||
vg.addColorStop(1, 'rgba(118,102,72,0.14)');
|
||
ctx.fillStyle = vg;
|
||
ctx.fillRect(BAND, BAND, W - 2 * BAND, H - 2 * BAND);
|
||
// Seam where the surface meets the band.
|
||
ctx.strokeStyle = 'rgba(96,82,56,0.28)';
|
||
ctx.lineWidth = 2;
|
||
roundRectPath(ctx, BAND, BAND, W - 2 * BAND, H - 2 * BAND, 22);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
// Outer rim of the mat.
|
||
ctx.strokeStyle = MAT.bandEdge;
|
||
ctx.lineWidth = 5;
|
||
roundRectPath(ctx, 2.5, 2.5, W - 5, H - 5, 26);
|
||
ctx.stroke();
|
||
// Stitching, centred in the border band.
|
||
stitchRect(ctx, 0, 0, W, H, 26, BAND / 2, [18, 12], 3.5);
|
||
return cv;
|
||
}
|
||
|
||
// Board pocket: a slightly deeper stitched panel that the reference picture
|
||
// (and the locked pieces) sit inside.
|
||
function makePocketTexture(size) {
|
||
const M = POCKET_MARGIN;
|
||
const S = size + 2 * M;
|
||
const cv = document.createElement('canvas');
|
||
cv.width = cv.height = S;
|
||
const ctx = cv.getContext('2d');
|
||
ctx.save();
|
||
roundRectPath(ctx, M, M, size, size, 16);
|
||
ctx.shadowColor = 'rgba(64,52,32,0.35)';
|
||
ctx.shadowBlur = 18;
|
||
ctx.shadowOffsetY = 6;
|
||
ctx.fillStyle = MAT.pocket;
|
||
ctx.fill();
|
||
ctx.restore();
|
||
ctx.save();
|
||
roundRectPath(ctx, M, M, size, size, 16);
|
||
ctx.clip();
|
||
ctx.fillStyle = ctx.createPattern(makeWeaveTile(4242), 'repeat');
|
||
ctx.fillRect(0, 0, S, S);
|
||
// Recessed feel: darker toward the top, a faint lift at the bottom lip.
|
||
const g = ctx.createLinearGradient(0, M, 0, M + size);
|
||
g.addColorStop(0, 'rgba(84,70,44,0.14)');
|
||
g.addColorStop(0.14, 'rgba(84,70,44,0.03)');
|
||
g.addColorStop(0.86, 'rgba(255,255,246,0.04)');
|
||
g.addColorStop(1, 'rgba(255,255,246,0.09)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(M, M, size, size);
|
||
ctx.restore();
|
||
ctx.strokeStyle = 'rgba(96,82,56,0.4)';
|
||
ctx.lineWidth = 2;
|
||
roundRectPath(ctx, M + 1, M + 1, size - 2, size - 2, 16);
|
||
ctx.stroke();
|
||
stitchRect(ctx, M, M, size, size, 16, 11, [14, 10], 3);
|
||
return cv;
|
||
}
|
||
|
||
function offsetOutline(o, ox, oy) {
|
||
return {
|
||
start: { x: o.start.x + ox, y: o.start.y + oy },
|
||
cmds: o.cmds.map((c) => c.t === 'line'
|
||
? { t: 'line', x: c.x + ox, y: c.y + oy }
|
||
: { t: 'bezier', c1: { x: c.c1.x + ox, y: c.c1.y + oy }, c2: { x: c.c2.x + ox, y: c.c2.y + oy }, x: c.x + ox, y: c.y + oy }),
|
||
};
|
||
}
|
||
|
||
// Load a picture as a plain HTMLImageElement (cached by URL). We draw pieces
|
||
// and thumbnails straight from this element rather than relying on Phaser's
|
||
// texture-source API, which differs across Phaser 3 versions.
|
||
const _imgCache = {};
|
||
function loadImage(url) {
|
||
if (_imgCache[url]) return Promise.resolve(_imgCache[url]);
|
||
return new Promise((resolve, reject) => {
|
||
const img = new Image();
|
||
img.onload = () => { _imgCache[url] = img; resolve(img); };
|
||
img.onerror = () => reject(new Error('image load failed: ' + url));
|
||
img.src = url;
|
||
});
|
||
}
|
||
|
||
export default class JigsawGame extends Phaser.Scene {
|
||
constructor() {
|
||
super('jigsaw-game');
|
||
this._pt = new Phaser.Math.Vector2();
|
||
}
|
||
|
||
init(data) {
|
||
this.gameDef = data?.game ?? { slug: 'jigsaw', name: 'Jigsaw' };
|
||
}
|
||
|
||
create() {
|
||
this.artwork = [];
|
||
this.imageIndex = 0;
|
||
this.buttons = [];
|
||
this.pieces = [];
|
||
this.state = 'menu';
|
||
this.dragging = null;
|
||
this.panning = null;
|
||
this.placed = 0;
|
||
this.zTop = 1000;
|
||
this.startTime = 0;
|
||
this.elapsed = 0;
|
||
this.hintOn = true;
|
||
this.selectedDiff = 'easy';
|
||
|
||
this.loadArtwork();
|
||
// Open the menu on a random picture, not always the first one, so each
|
||
// visit to the puzzle picker starts on a different puzzle. (loadArtwork
|
||
// guarantees at least one entry, so this index is always valid.)
|
||
this.imageIndex = Math.floor(Math.random() * this.artwork.length);
|
||
this.buildBackground();
|
||
this.buildHUD();
|
||
this.buildMenu();
|
||
this.bindInput();
|
||
|
||
this.cameras.main.setBounds(0, HUD_H, WORLD_W, WORLD_H - HUD_H);
|
||
this.cameras.main.setZoom(1);
|
||
this.cameras.main.setScroll(0, 0);
|
||
|
||
// ── Fixed HUD camera ─────────────────────────────────────────────────
|
||
// A second camera locked at (0,0) zoom 1 renders the HUD + win overlay
|
||
// in screen space, so they never move or scale with the board camera.
|
||
this.mainCam = this.cameras.main;
|
||
this.hudCam = this.cameras.add(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||
this.hudCam.scrollX = 0;
|
||
this.hudCam.scrollY = 0;
|
||
this.hudCam.setZoom(1);
|
||
|
||
// Route world objects to the main camera only, and UI (HUD/menu) to the
|
||
// fixed hudCam only, so the two never overlap on the same camera.
|
||
const worldOnly = (o) => { if (o) o.cameraFilter = this.hudCam.id; return o; };
|
||
const screenOnly = (o) => { if (o) o.cameraFilter = this.mainCam.id; return o; };
|
||
worldOnly(this.bg);
|
||
worldOnly(this.boardPanel);
|
||
worldOnly(this.boardFrame);
|
||
worldOnly(this.refImage);
|
||
screenOnly(this.hud);
|
||
screenOnly(this.menu);
|
||
screenOnly(this.ddPanel);
|
||
|
||
// ── Site soundtrack ────────────────────────────────────────────────────
|
||
// The shared default soundtrack (data/music.json, preloaded in PreloadScene);
|
||
// getGameSoundtrack() transparently resolves a per-game override if one is
|
||
// ever added for the 'jigsaw' slug. The music HUD (skip/mute buttons +
|
||
// track name) is shared MusicPlayer UI: pin it to the fixed HUD camera
|
||
// (it must not follow the panning world camera) and above the top bar so
|
||
// the bar's translucent fill doesn't dim it.
|
||
try {
|
||
const { tracks, volume } = getGameSoundtrack(this);
|
||
if (tracks.length) {
|
||
this.music = new MusicPlayer(this, tracks, volume);
|
||
for (const o of this.music._objs) screenOnly(o).setDepth(9100);
|
||
}
|
||
} catch (_) { /* music is optional */ }
|
||
|
||
this.state = 'menu';
|
||
}
|
||
|
||
// Route a game object so it renders on all cameras except `cam`.
|
||
excludeFrom(obj, cam) { if (obj && cam) obj.cameraFilter = cam.id; return obj; }
|
||
|
||
// ── Artwork (Shift images, lazy-loaded) ────────────────────────────────────
|
||
loadArtwork() {
|
||
const data = this.cache.json.get('shift-artwork');
|
||
const list = (data && data.artwork) || (Array.isArray(data) ? data : []);
|
||
this.artwork = list.filter((a) => a && a.path);
|
||
if (!this.artwork.length) this.artwork = [{ name: 'Puzzle', path: 'assets/images/shift/alien-world.png' }];
|
||
}
|
||
|
||
currentImage() { return this.artwork[this.imageIndex % this.artwork.length]; }
|
||
|
||
previewImage(dir) {
|
||
this.imageIndex = (this.imageIndex + dir + this.artwork.length) % this.artwork.length;
|
||
const item = this.currentImage();
|
||
playSound(this, SFX.UI_FLIP);
|
||
this.loadPreview(item);
|
||
}
|
||
|
||
randomImage() {
|
||
let i = this.imageIndex;
|
||
while (i === this.imageIndex && this.artwork.length > 1) i = Math.floor(Math.random() * this.artwork.length);
|
||
this.imageIndex = i;
|
||
playSound(this, SFX.UI_ACTIVATE);
|
||
this.loadPreview(this.currentImage());
|
||
}
|
||
|
||
loadPreview(item) {
|
||
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
|
||
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
|
||
const key = item.path;
|
||
this._loadToken = (this._loadToken || 0) + 1;
|
||
const token = this._loadToken;
|
||
loadImage(key).then((srcImg) => {
|
||
if (token !== this._loadToken) return; // superseded by a newer pick
|
||
if (!this.menu || !this.menu.visible) return;
|
||
const box = this.thumbBox || 280, cx = this.thumbBoxX, cy = this.thumbBoxY;
|
||
const S = box;
|
||
const cv = document.createElement('canvas');
|
||
cv.width = cv.height = S;
|
||
const ctx = cv.getContext('2d');
|
||
ctx.save();
|
||
const rr = 16;
|
||
ctx.beginPath();
|
||
ctx.moveTo(rr, 0); ctx.arcTo(S, 0, S, S, rr); ctx.arcTo(S, S, 0, S, rr);
|
||
ctx.arcTo(0, S, 0, 0, rr); ctx.arcTo(0, 0, S, 0, rr); ctx.closePath();
|
||
ctx.clip();
|
||
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
|
||
const sc = Math.max(S / iw, S / ih);
|
||
ctx.drawImage(srcImg, (S - iw * sc) / 2, (S - ih * sc) / 2, iw * sc, ih * sc);
|
||
ctx.restore();
|
||
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
|
||
this.textures.addCanvas('jigsaw-thumb', cv);
|
||
const img = this.add.image(cx, cy, 'jigsaw-thumb').setDisplaySize(box, box);
|
||
this.menu.add(img);
|
||
img.setPosition(cx, cy);
|
||
this.previewImg = img;
|
||
this.addThumbBorder();
|
||
if (this.thumbName) this.thumbName.setText(item.name || 'Image');
|
||
}).catch((e) => { console.warn('jigsaw preview:', e.message); });
|
||
}
|
||
|
||
addThumbBorder() {
|
||
if (this.thumbBorder) this.thumbBorder.destroy();
|
||
const g = this.add.graphics();
|
||
const h = (this.thumbBox || 280) / 2 - 10;
|
||
g.lineStyle(3, COLORS.accent, 0.9);
|
||
g.strokeRoundedRect(-h, -h, h * 2, h * 2, 14);
|
||
this.thumbBorder = g;
|
||
const parent = this.menu || this;
|
||
if (parent.add) parent.add(g);
|
||
g.setPosition(this.thumbBoxX, this.thumbBoxY);
|
||
}
|
||
|
||
// ── Background / table ─────────────────────────────────────────────────────
|
||
buildBackground() {
|
||
// The playfield is one stitched beige mat (band + stitching + weave).
|
||
if (!this.textures.exists('jigsaw-mat')) {
|
||
this.textures.addCanvas('jigsaw-mat', makeMatTexture());
|
||
}
|
||
this.bg = this.add.image(WORLD_W / 2, (WORLD_H + MAT_OVERSCAN) / 2, 'jigsaw-mat').setDepth(0);
|
||
// Invisible hit zone for the table (pan on empty space). Kept separate
|
||
// from the mat image so the background can be swapped without dropping
|
||
// the input. Zones are center-origin, so position at the world midpoint.
|
||
const hit = this.add.zone(WORLD_W / 2, WORLD_H / 2, WORLD_W, WORLD_H).setDepth(0);
|
||
hit.setInteractive();
|
||
hit.on('pointerdown', (pointer) => this.onTableDown(pointer));
|
||
this.bgHit = hit;
|
||
}
|
||
|
||
// ── HUD (pinned to the top of the screen regardless of pan/zoom) ──────────
|
||
buildHUD() {
|
||
this.hud = this.add.container(0, 0).setDepth(9000);
|
||
const bar = this.add.rectangle(GAME_WIDTH / 2, HUD_H / 2, GAME_WIDTH, HUD_H, 0x000000, 0.32);
|
||
const line = this.add.rectangle(GAME_WIDTH / 2, HUD_H, GAME_WIDTH, 3, COLORS.accent, 0.7);
|
||
this.hud.add([bar, line]);
|
||
|
||
this.title = this.add.text(72, HUD_H / 2, 'JIGSAW', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
|
||
letterSpacing: 4,
|
||
}).setOrigin(0.5, 0.5);
|
||
this.hud.add(this.title);
|
||
|
||
this.diffBadge = this.add.text(250, HUD_H / 2, 'Easy · 25', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex,
|
||
}).setOrigin(0.5, 0.5);
|
||
this.hud.add(this.diffBadge);
|
||
|
||
this.stats = this.add.text(GAME_WIDTH / 2, HUD_H / 2, '', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
||
}).setOrigin(0.5, 0.5);
|
||
this.hud.add(this.stats);
|
||
|
||
// The top bar keeps a single "Menu ▾" button: a dropdown that holds all
|
||
// the other actions (Reset view, Zoom in/out, Hint, New puzzle, Main menu).
|
||
// It sits left of the top-right corner, which the site's shared music HUD
|
||
// (skip/mute + track name, see create()) reserves.
|
||
this.btnMenu = this.mkButton(this.hud, 'Menu ▾', 1480, HUD_H / 2, 150, 54, () => this.toggleMenu());
|
||
this.buildMenuPanel();
|
||
|
||
this.hudVisible = () => this.hud.visible;
|
||
this.setHUDForState('menu');
|
||
}
|
||
|
||
// ── Menu dropdown (screen space, under the top-bar trigger) ────────────────
|
||
// Container sits at (0,0) like the HUD so buttonHit() can use absolute
|
||
// coordinates for its rows. Toggled by btnMenu; dismissed by any outside
|
||
// click (handled in onTableDown) or by state changes (setHUDForState).
|
||
buildMenuPanel() {
|
||
const X0 = 1315, X1 = 1555; // right edge aligns with the trigger
|
||
const W = X1 - X0, cx = (X0 + X1) / 2;
|
||
const BH = 44, GAP = 8, PAD = 12, SEP = 26;
|
||
const top = HUD_H + 10;
|
||
const h = PAD + 5 * BH + 4 * GAP + SEP + BH + PAD; // 5 rows + separator + Main menu
|
||
const panel = this.add.container(0, 0).setDepth(9050);
|
||
panel.setVisible(false);
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0x17130c, 0.98).fillRoundedRect(X0, top, W, h, 12);
|
||
bg.lineStyle(2, COLORS.accent, 0.8).strokeRoundedRect(X0, top, W, h, 12);
|
||
panel.add(bg);
|
||
|
||
const rowY = (i) => top + PAD + BH / 2 + i * (BH + GAP);
|
||
const row = (i, label, onClick) => this.mkButton(panel, label, cx, rowY(i), W - 24, BH, onClick, { fontSize: 20 });
|
||
this.btnHome = row(0, '⌂ Reset view', () => { this.resetView(); this.closeMenu(); });
|
||
this.btnZoomIn = row(1, '+ Zoom in', () => { this.zoomStep(1.2); this.closeMenu(); });
|
||
this.btnZoomOut = row(2, '- Zoom out', () => { this.zoomStep(1 / 1.2); this.closeMenu(); });
|
||
this.btnHint = row(3, 'Hint', () => this.toggleHint()); // stays open: it's a toggle
|
||
this.btnNew = row(4, 'New puzzle', () => { this.restart(); this.closeMenu(); });
|
||
|
||
const sepY = top + PAD + 5 * BH + 4 * GAP + SEP / 2;
|
||
panel.add(this.add.rectangle(cx, sepY, W - 36, 2, COLORS.accent, 0.5));
|
||
this.btnMain = this.mkButton(panel, 'Main menu', cx, sepY + SEP / 2 + BH / 2, W - 24, BH,
|
||
() => { this.toMenu(); this.closeMenu(); }, { fontSize: 20 });
|
||
|
||
this.btnHint.setActive(this.hintOn);
|
||
this.ddPanel = panel;
|
||
this.ddOpen = false;
|
||
}
|
||
|
||
toggleMenu() {
|
||
if (this.state !== 'playing' && this.state !== 'won') return;
|
||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||
if (this.ddOpen) this.closeMenu(); else this.openMenu();
|
||
}
|
||
|
||
openMenu() {
|
||
if (this.ddOpen) return;
|
||
this.ddOpen = true;
|
||
this.btnHint.setActive(this.hintOn); // keep the toggle row in sync
|
||
this.ddPanel.setVisible(true);
|
||
this.btnMenu.setLabel('Menu ▴');
|
||
}
|
||
|
||
closeMenu() {
|
||
if (!this.ddPanel || !this.ddOpen) return;
|
||
this.ddOpen = false;
|
||
this.ddPanel.setVisible(false);
|
||
this.btnMenu.setLabel('Menu ▾');
|
||
}
|
||
|
||
setHUDForState(state) {
|
||
const playing = state === 'playing' || state === 'won';
|
||
this.btnMenu.visible = playing;
|
||
if (this.ddPanel) { this.ddOpen = false; this.ddPanel.setVisible(false); } // state changes collapse the dropdown
|
||
this.diffBadge.visible = playing;
|
||
this.stats.visible = playing;
|
||
this.title.setVisible(true);
|
||
}
|
||
|
||
// ── Button factory (registered for hit-testing + cleanup) ──────────────────
|
||
mkButton(parent, label, x, y, w, h, onClick, o = {}) {
|
||
// Swallow a click that is really the tail-end of a piece drag (Phaser fires
|
||
// pointerup on whatever is under the cursor at release, even mid-drag).
|
||
const safe = () => {
|
||
if (this.suppressNextButtonClick) { this.suppressNextButtonClick = false; return; }
|
||
if (onClick) onClick();
|
||
};
|
||
const b = new Button(this, x, y, label, safe, {
|
||
width: w, height: h,
|
||
fontSize: o.fontSize || 24,
|
||
variant: o.variant || 'solid',
|
||
bg: o.bg || COLORS.panel,
|
||
});
|
||
parent.add(b);
|
||
this.buttons.push(b);
|
||
return b;
|
||
}
|
||
|
||
buttonHit(cx, cy) {
|
||
for (const b of this.buttons) {
|
||
if (!b.visible || !b.parent || !b.parent.visible) continue;
|
||
const w = b.options.width, h = b.options.height;
|
||
if (Math.abs(cx - b.x) <= w / 2 && Math.abs(cy - b.y) <= h / 2) return b;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Menu ───────────────────────────────────────────────────────────────────
|
||
buildMenu() {
|
||
this.menu = this.add.container(0, 0).setDepth(8000);
|
||
|
||
// Two comfortably-spaced columns — picture picker left, difficulty +
|
||
// start right — with a consistent vertical rhythm (26px label→control,
|
||
// ≥50px between sections) instead of one tall, cramped stack.
|
||
const W = 1180, H = 660;
|
||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||
const top = cy - H / 2;
|
||
const bg = this.add.graphics();
|
||
bg.fillStyle(0x17130c, 0.96).fillRoundedRect(cx - W / 2, top, W, H, 20);
|
||
bg.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, top, W, H, 20);
|
||
this.menu.add(bg);
|
||
|
||
const addT = (x, y, s, color, o = {}) => {
|
||
const t = this.add.text(x, y, s, { fontFamily: '"Julius Sans One"', fontSize: o.size, color: color, ...o }).setOrigin(0.5);
|
||
this.menu.add(t); return t;
|
||
};
|
||
|
||
addT(cx, top + 72, 'JIGSAW', COLORS.textHex, { size: '48px', letterSpacing: 8 });
|
||
addT(cx, top + 122, 'Assemble the picture.', COLORS.mutedHex, { size: '20px' });
|
||
|
||
const labelY = top + 202;
|
||
const cxL = cx - 292, cxR = cx + 286; // right column kept inboard so the
|
||
// difficulty pair stays inside the panel
|
||
addT(cxL, labelY, 'Image', COLORS.goldHex, { size: '22px', letterSpacing: 3 });
|
||
addT(cxR, labelY, 'Difficulty', COLORS.goldHex, { size: '22px', letterSpacing: 3 });
|
||
|
||
// ── Left column: picture picker ───────────────────────────────────────
|
||
this.thumbBox = 280;
|
||
const thumbCy = labelY + 11 + 26 + this.thumbBox / 2;
|
||
this.thumbBoxX = cxL; this.thumbBoxY = thumbCy;
|
||
this.mkButton(this.menu, '‹', cxL - 190, thumbCy, 64, 64, () => this.previewImage(-1), { fontSize: 34 });
|
||
this.mkButton(this.menu, '›', cxL + 190, thumbCy, 64, 64, () => this.previewImage(1), { fontSize: 34 });
|
||
const nameY = thumbCy + this.thumbBox / 2 + 26;
|
||
this.thumbName = addT(cxL, nameY, '', COLORS.textHex, { size: '20px' });
|
||
this.mkButton(this.menu, '🎲 Random', cxL, nameY + 10 + 22 + 26, 190, 52, () => this.randomImage(), { fontSize: 22 });
|
||
|
||
// ── Right column: difficulty + start ──────────────────────────────────
|
||
const bw = 190, bh = 58, gap = 18;
|
||
const row1 = labelY + 11 + 26 + bh / 2;
|
||
const row2 = row1 + bh + gap;
|
||
const bxL = cxR - (bw + gap) / 2 - bw / 2;
|
||
const bxR = cxR + (bw + gap) / 2 + bw / 2;
|
||
this.diffButtons = [];
|
||
const bx = [bxL, bxR], by = [row1, row2];
|
||
DIFFICULTY_ORDER.forEach((k, i) => {
|
||
const cfg = DIFFICULTIES[k];
|
||
const b = this.mkButton(this.menu, `${cfg.label} · ${cfg.cols * cfg.rows}`, bx[i % 2], by[Math.floor(i / 2)], bw, bh, () => this.selectDifficulty(k), { fontSize: 21 });
|
||
this.diffButtons.push({ key: k, btn: b });
|
||
});
|
||
|
||
const startY = row2 + bh / 2 + 52 + 37;
|
||
const start = this.mkButton(this.menu, 'Start Puzzle ▸', cxR, startY, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold });
|
||
this.startButton = start;
|
||
addT(cxR, startY + 37 + 40, 'Drag pieces onto the mat · scroll to zoom · drag to pan', COLORS.mutedHex, { size: '17px' });
|
||
|
||
// Load the first preview
|
||
this.selectDifficulty('easy');
|
||
this.loadPreview(this.currentImage());
|
||
}
|
||
|
||
selectDifficulty(key) {
|
||
this.selectedDiff = key;
|
||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||
this.diffButtons.forEach((d) => d.btn.setActive(d.key === key));
|
||
}
|
||
|
||
// ── Puzzle start ───────────────────────────────────────────────────────────
|
||
startPuzzle() {
|
||
const item = this.currentImage();
|
||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||
this.menu.setVisible(false);
|
||
this.setState('loading');
|
||
this.showLoading(true);
|
||
loadImage(item.path).then(() => {
|
||
this.showLoading(false);
|
||
this.beginPlay(item);
|
||
}).catch((e) => {
|
||
this.showLoading(false);
|
||
console.error('jigsaw image load failed:', e.message);
|
||
this.setState('menu');
|
||
this.menu.setVisible(true);
|
||
});
|
||
}
|
||
|
||
showLoading(on) {
|
||
if (on && !this.loadingLabel) {
|
||
this.loadingLabel = this.add.text(GAME_WIDTH / 2, GAME_HEIGHT / 2, 'Loading…', {
|
||
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
|
||
}).setOrigin(0.5).setDepth(9500);
|
||
if (this.mainCam) this.loadingLabel.cameraFilter = this.mainCam.id;
|
||
} else if (this.loadingLabel) this.loadingLabel.setVisible(on);
|
||
}
|
||
|
||
setState(s) {
|
||
this.state = s;
|
||
this.setHUDForState(s);
|
||
if (s === 'menu') {
|
||
this.menu.setVisible(true);
|
||
this.setBoardVisible(false);
|
||
this.resetToCanvas();
|
||
} else {
|
||
this.menu.setVisible(false);
|
||
}
|
||
}
|
||
|
||
// ── Build board + pieces ───────────────────────────────────────────────────
|
||
beginPlay(item) {
|
||
this.teardownPlay();
|
||
const cfg = DIFFICULTIES[this.selectedDiff];
|
||
this.cols = cfg.cols; this.rows = cfg.rows;
|
||
this.total = cfg.cols * cfg.rows;
|
||
this.cell = BOARD_SIZE / cfg.cols;
|
||
this.placed = 0;
|
||
this.startTime = 0; this.elapsed = 0;
|
||
this.seed = (Math.random() * 1e9) | 0;
|
||
this.jig = makeJigsaw(this.cols, this.rows, this.seed);
|
||
this.difficulty = this.selectedDiff;
|
||
this.diffLabel = `${cfg.label} · ${this.total} pieces`;
|
||
this.imageName = item.name || 'Image';
|
||
|
||
// Board: a stitched pocket set a shade deeper than the field, with the
|
||
// faint reference picture laid inside it.
|
||
const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2;
|
||
if (!this.textures.exists('jigsaw-pocket')) {
|
||
this.textures.addCanvas('jigsaw-pocket', makePocketTexture(BOARD.size));
|
||
}
|
||
const panel = this.add.image(bcx, bcy, 'jigsaw-pocket')
|
||
.setDisplaySize(BOARD.size + 2 * POCKET_MARGIN, BOARD.size + 2 * POCKET_MARGIN)
|
||
.setDepth(1);
|
||
const boardFrame = this.add.graphics().setDepth(2);
|
||
boardFrame.lineStyle(2, 0x5a4c32, 0.25);
|
||
boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size);
|
||
this.boardPanel = panel;
|
||
this.boardFrame = boardFrame;
|
||
// Keep board on the main camera only (the fixed HUD camera would draw its
|
||
// top-left corner into the lower-right of the screen).
|
||
if (this.hudCam) { panel.cameraFilter = this.hudCam.id; boardFrame.cameraFilter = this.hudCam.id; }
|
||
|
||
const srcImg = _imgCache[item.path];
|
||
// Build the faint reference into a canvas texture (cover-cropped square),
|
||
// then place it. Texture must exist before the image references it.
|
||
const R = 640;
|
||
const rcv = document.createElement('canvas');
|
||
rcv.width = rcv.height = R;
|
||
const rctx = rcv.getContext('2d');
|
||
if (srcImg) {
|
||
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
|
||
const sc = Math.max(R / iw, R / ih);
|
||
rctx.drawImage(srcImg, (R - iw * sc) / 2, (R - ih * sc) / 2, iw * sc, ih * sc);
|
||
} else {
|
||
rctx.fillStyle = '#5a7b8a'; rctx.fillRect(0, 0, R, R);
|
||
}
|
||
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
|
||
this.textures.addCanvas('jigsaw-ref', rcv);
|
||
this.refImage = this.add.image(bcx, bcy, 'jigsaw-ref').setDisplaySize(BOARD.size, BOARD.size).setAlpha(0.2).setDepth(3);
|
||
if (this.hudCam) this.refImage.cameraFilter = this.hudCam.id; // main camera only
|
||
// Faint full-picture reference: keep on Easy/Medium, hide on Hard/Legendary.
|
||
this.refImage.setVisible(this.difficulty !== 'hard' && this.difficulty !== 'legendary');
|
||
|
||
// Build piece textures + sprites
|
||
const texKey = (r, c) => `jg-${this.seed}-${r}-${c}`;
|
||
const boardW = this.cols * this.cell, boardH = this.rows * this.cell;
|
||
|
||
this.pieces = [];
|
||
for (let r = 0; r < this.rows; r++) {
|
||
for (let c = 0; c < this.cols; c++) {
|
||
const key = texKey(r, c);
|
||
const cv = this.buildPieceCanvas(r, c, srcImg, boardW, boardH);
|
||
if (this.textures.exists(key)) this.textures.remove(key);
|
||
this.textures.addCanvas(key, cv);
|
||
const home = { x: BOARD.x + (c + 0.5) * this.cell, y: BOARD.y + (r + 0.5) * this.cell };
|
||
const img = this.add.image(0, 0, key).setOrigin(0.5).setDepth(10);
|
||
if (this.hudCam) img.cameraFilter = this.hudCam.id; // world camera only
|
||
// Hit area = the piece's own footprint (base cell + inner knob), NOT 2×cell.
|
||
// Pieces scatter with a minimum centre-gap of ~1.45×cell, so keeping the
|
||
// radius at 0.6×cell guarantees a pointer can only ever fall inside ONE
|
||
// piece's hit area. (With the old ±cell box, ~35 neighbour pairs overlapped
|
||
// and the click resolved to whichever had the highest index — the bottom-
|
||
// right piece — far away from the cursor.)
|
||
// Pieces stay interactive for the hover cursor AND to consume the
|
||
// pointerdown (forwarding it to the central grab resolver). Without the
|
||
// pointerdown handler here, a click that lands inside the piece's custom
|
||
// hit area is consumed by the piece (topOnly=true) and never reaches the
|
||
// background's onTableDown — leaving the piece un-grabbable.
|
||
const grab = this.cell * GRAB_FRAC;
|
||
img.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(img.displayOriginX - grab, img.displayOriginY - grab, grab * 2, grab * 2), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
|
||
img.on('pointerdown', (pointer) => this.onTableDown(pointer));
|
||
this.pieces.push({ r, c, img, home, placed: false, key, depth: 10 });
|
||
}
|
||
}
|
||
|
||
// Scatter pieces around (but off) the board. scatterSpots always returns
|
||
// exactly `total` spots; the `|| { x: BOARD.x, y: BOARD.y }` is just a
|
||
// last-resort guard so a layout change can never crash the game.
|
||
const spots = this.scatterSpots(this.total);
|
||
this.pieces.forEach((p, i) => {
|
||
const s = spots[i] || { x: BOARD.x + BOARD.size / 2, y: BOARD.y + BOARD.size / 2 };
|
||
this.setPiecePos(p, s.x, s.y);
|
||
});
|
||
|
||
// ── Piece groups ─────────────────────────────────────────────────────────
|
||
// Each piece starts as a singleton group. When two grid-adjacent pieces are
|
||
// dropped within snap radius of their correct relative offset they merge
|
||
// into one group and from then on drag, drop and lock as a single piece.
|
||
// Invariant: every group member sits at `anchorPos + (member.home - anchor.home)`
|
||
// for any member `anchor` — i.e. the exact board-relative offset — so the
|
||
// knob geometry always meshes while a group moves.
|
||
this.groups = new Set();
|
||
this.pieceGrid = [];
|
||
for (let r = 0; r < this.rows; r++) this.pieceGrid.push(new Array(this.cols));
|
||
for (const p of this.pieces) {
|
||
const g = { pieces: [p] };
|
||
p.group = g;
|
||
this.groups.add(g);
|
||
this.pieceGrid[p.r][p.c] = p;
|
||
}
|
||
|
||
this.updateStats();
|
||
this.setState('playing');
|
||
this.resetView();
|
||
}
|
||
|
||
buildPieceCanvas(r, c, src, boardW, boardH) {
|
||
const cell = this.cell;
|
||
const PAD = Math.max(7, cell * 0.09); // headroom for the drop shadow
|
||
const S = Math.ceil(cell * 2) + Math.ceil(PAD * 2);
|
||
const cv = document.createElement('canvas');
|
||
cv.width = cv.height = S;
|
||
const ctx = cv.getContext('2d');
|
||
const ox = S / 2 - (c + 0.5) * cell;
|
||
const oy = S / 2 - (r + 0.5) * cell;
|
||
const outline = offsetOutline(cellOutline(this.jig, r, c, cell, cell), ox, oy);
|
||
|
||
// Soft drop shadow so pieces read as resting on the mat. The fill colour
|
||
// is arbitrary — the opaque art pass below repaints the piece exactly;
|
||
// only the shadow escaping the outline is visible.
|
||
ctx.save();
|
||
tracePath(ctx, outline);
|
||
ctx.shadowColor = 'rgba(58,46,26,0.30)';
|
||
ctx.shadowBlur = Math.max(4, cell * 0.07);
|
||
ctx.shadowOffsetY = Math.max(3, cell * 0.05);
|
||
ctx.fillStyle = '#3a2f1a';
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
ctx.save();
|
||
tracePath(ctx, outline);
|
||
ctx.clip();
|
||
if (src) ctx.drawImage(src, ox, oy, boardW, boardH);
|
||
else {
|
||
// Fallback swatch so a piece is still visible if the image is missing.
|
||
const hue = ((r * this.cols + c) * 47) % 360;
|
||
ctx.fillStyle = `hsl(${hue} 45% 60%)`;
|
||
ctx.fillRect(0, 0, S, S);
|
||
}
|
||
// depth shading
|
||
const g = ctx.createLinearGradient(0, 0, 0, S);
|
||
g.addColorStop(0, 'rgba(255,255,255,0.10)');
|
||
g.addColorStop(0.5, 'rgba(255,255,255,0)');
|
||
g.addColorStop(1, 'rgba(0,0,0,0.20)');
|
||
ctx.fillStyle = g;
|
||
ctx.fillRect(0, 0, S, S);
|
||
ctx.restore();
|
||
|
||
tracePath(ctx, outline);
|
||
ctx.lineJoin = 'round';
|
||
ctx.lineWidth = Math.max(1.5, cell * 0.028);
|
||
ctx.strokeStyle = 'rgba(12,9,6,0.6)';
|
||
ctx.stroke();
|
||
tracePath(ctx, outline);
|
||
ctx.lineWidth = Math.max(1, cell * 0.014);
|
||
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
|
||
ctx.stroke();
|
||
return cv;
|
||
}
|
||
|
||
scatterSpots(n) {
|
||
const g = mulberry32(this.seed ^ 0x9e3779b9);
|
||
// Tray = the whole (bigger) field minus a small margin, EXCLUDING the board
|
||
// (pieces must not scatter on top of the target image or they'd be
|
||
// indistinguishable from placed pieces). The HUD lives in viewport space,
|
||
// so it doesn't reserve any of the world.
|
||
const tray = { x: 40, y: HUD_H + 40, w: WORLD_W - 80, h: WORLD_H - HUD_H - 80 };
|
||
const boardBox = { x0: BOARD.x - 6, y0: BOARD.y - 6, x1: BOARD.x + BOARD.size + 6, y1: BOARD.y + BOARD.size + 6 };
|
||
const grabR = this.cell * GRAB_FRAC;
|
||
// We guarantee a comfortable minimum centre-gap so no two pieces steal each
|
||
// other's grab region (the old rejection-scatter over-packed the table and
|
||
// left some pieces ungrabbable until their neighbours were placed).
|
||
//
|
||
// KEY: candidate CENTRES are computed without jitter, so their COUNT is
|
||
// deterministic for a given spacing (the old code jittered first and then
|
||
// dropped points that wandered off-table / under the board, so the count was
|
||
// seed-dependent and sometimes came up short — crashing beginPlay with
|
||
// `undefined.x`). We start roomy and tighten the grid until it holds >= n
|
||
// centres. The floor is small enough that a fit always exists, so we never
|
||
// come up short and never emit an off-canvas spot.
|
||
const floor = this.cell; // densest we'll allow — one base-cell apart
|
||
let spacing = this.cell * 1.7;
|
||
let cells = [];
|
||
for (let i = 0; i < 40; i++) {
|
||
cells = this.gridCells(tray, boardBox, spacing, grabR);
|
||
if (cells.length >= n) break;
|
||
spacing = Math.max(floor, spacing * 0.95);
|
||
}
|
||
|
||
// Cosmetic jitter for a natural look. Only jitter cells that sit safely away
|
||
// from the board and the tray edges so the jitter can never push a piece
|
||
// under the board or off the table. When the grid is tight (spacing <= 2x
|
||
// grab) we skip jitter so the centre-gap never shrinks below the fit.
|
||
const J = Math.max(0, spacing - 2 * grabR) * 0.2;
|
||
const safe = (p) => {
|
||
const bx0 = boardBox.x0 - J, by0 = boardBox.y0 - J, bx1 = boardBox.x1 + J, by1 = boardBox.y1 + J;
|
||
const tx0 = tray.x + grabR + J, tx1 = tray.x + tray.w - grabR - J;
|
||
const ty0 = tray.y + grabR + J, ty1 = tray.y + tray.h - grabR - J;
|
||
return !(p.x > bx0 && p.x < bx1 && p.y > by0 && p.y < by1) &&
|
||
(p.x > tx0 && p.x < tx1 && p.y > ty0 && p.y < ty1);
|
||
};
|
||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
|
||
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
|
||
const pts = cells.map((p) => safe(p) && J > 0
|
||
? { x: clamp(p.x + (g() - 0.5) * 2 * J, x0, x1), y: clamp(p.y + (g() - 0.5) * 2 * J, y0, y1) }
|
||
: { x: clamp(p.x, x0, x1), y: clamp(p.y, y0, y1) });
|
||
|
||
// Defensive backfill (should never be needed with the floor above): add spots
|
||
// in a guaranteed-clear left strip, staying strictly inside the tray so we
|
||
// can never emit an off-canvas position.
|
||
if (pts.length < n) {
|
||
let k = pts.length;
|
||
while (pts.length < n) {
|
||
const col = k % 3, row = Math.floor(k / 3);
|
||
pts.push({
|
||
x: clamp(tray.x + grabR + col * 2 * grabR, x0, x1),
|
||
y: clamp(tray.y + grabR + row * 2 * grabR, y0, y1),
|
||
});
|
||
k++;
|
||
}
|
||
}
|
||
|
||
// shuffle so pieces aren't in grid order, then take n
|
||
for (let i = pts.length - 1; i > 0; i--) { const j = (g() * (i + 1)) | 0; const t = pts[i]; pts[i] = pts[j]; pts[j] = t; }
|
||
return pts.slice(0, n);
|
||
}
|
||
|
||
// Un-jittered grid CENTRES inside the tray, clear of the board. Count is
|
||
// deterministic in `spacing` (no RNG), so the caller can rely on it to
|
||
// guarantee a fit. Centres are kept a full grab-radius away from the board.
|
||
gridCells(tray, boardBox, spacing, grabR) {
|
||
const out = [];
|
||
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
|
||
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
|
||
const cs = Math.round(x0 / spacing), ce = Math.floor(x1 / spacing);
|
||
const rs = Math.round(y0 / spacing), re = Math.floor(y1 / spacing);
|
||
for (let r = rs; r <= re; r++) {
|
||
for (let c = cs; c <= ce; c++) {
|
||
const x = c * spacing, y = r * spacing;
|
||
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
|
||
if (x > boardBox.x0 && x < boardBox.x1 && y > boardBox.y0 && y < boardBox.y1) continue;
|
||
out.push({ x, y });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── Interaction ────────────────────────────────────────────────────────────
|
||
bindInput() {
|
||
this.input.on('pointermove', (pointer) => this.onPointerMove(pointer));
|
||
this.input.on('pointerup', () => this.onPointerUp());
|
||
this.input.on('pointerupoutside', () => this.onPointerUp());
|
||
this.input.on('wheel', (pointer, _o, _dx, dy) => {
|
||
if (this.state !== 'playing') return;
|
||
const c = this.canvasPos(pointer);
|
||
this.zoomAt(c.x, c.y, dy < 0 ? 1.15 : 1 / 1.15);
|
||
});
|
||
}
|
||
|
||
canvasPos(pointer) {
|
||
// pointer.x/y are already in screen (game-canvas) space per the Phaser 3.90
|
||
// Pointer API ("The value is in screen space"). Phaser 3.90 has no
|
||
// ScaleManager.getPointerPosition, so we read the pointer directly.
|
||
return { x: pointer.x, y: pointer.y };
|
||
}
|
||
|
||
worldOf(pointer) {
|
||
// Convert screen (canvas) space -> world space through the active camera
|
||
// matrix (accounts for scroll + zoom). Deterministic: reads the current
|
||
// pointer position and the current camera matrix.
|
||
const c = this.canvasPos(pointer);
|
||
const w = this.cameras.main.getWorldPoint(c.x, c.y);
|
||
return { x: w.x, y: w.y };
|
||
}
|
||
|
||
nearestPiece(w) {
|
||
if (this.state !== 'playing') return null;
|
||
const thr = this.cell * GRAB_FRAC; // grab radius: covers the piece body incl. its knobs
|
||
let best = null, bestD = Infinity;
|
||
for (const p of this.pieces) {
|
||
if (p.placed) continue;
|
||
const d = Math.hypot(p.pos.x - w.x, p.pos.y - w.y);
|
||
if (d < thr && d < bestD) { bestD = d; best = p; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
onTableDown(pointer) {
|
||
if (this.state !== 'playing') return;
|
||
if (this.dragging) return; // a grab is already in progress (both the piece and the table zone forward their pointerdown here — resolve only once)
|
||
const c = this.canvasPos(pointer);
|
||
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons (incl. dropdown rows) work
|
||
if (this.music && c.x > 1830 && c.y < 80) return; // site music HUD (top-right) — don't pan over it
|
||
if (this.ddOpen) { this.closeMenu(); return; } // outside click dismisses the dropdown
|
||
const piece = this.nearestPiece(this.worldOf(pointer));
|
||
if (piece) { this.grabPiece(piece, pointer); return; }
|
||
const cam = this.cameras.main; // empty table → pan
|
||
this.panning = { cx: c.x, cy: c.y, camX: cam.scrollX, camY: cam.scrollY };
|
||
}
|
||
|
||
// Single source of truth for a piece's table position: `p.pos`, mirrored
|
||
// onto the sprite. resolveDrop() (JigsawLogic.js) reasons about `pos` only.
|
||
setPiecePos(p, x, y) {
|
||
p.pos = { x, y };
|
||
p.img.setPosition(x, y);
|
||
}
|
||
|
||
grabPiece(piece, pointer) {
|
||
const w = this.worldOf(pointer);
|
||
const group = piece.group;
|
||
// Lift the whole group above everything else so it reads as one lifted piece.
|
||
this.zTop += 1;
|
||
for (const m of group.pieces) { m.depth = this.zTop; m.img.setDepth(this.zTop); }
|
||
this.dragging = { group, anchor: piece, offX: piece.pos.x - w.x, offY: piece.pos.y - w.y };
|
||
this.suppressNextButtonClick = true;
|
||
// Ghosts (brighter alpha preview of each member's home slot) on Easy only.
|
||
this.showGroupGhosts(group);
|
||
playSound(this, SFX.UI_PICK);
|
||
}
|
||
|
||
onPointerMove(pointer) {
|
||
const c = this.canvasPos(pointer);
|
||
if (this.dragging) {
|
||
const w = this.worldOf(pointer);
|
||
const { group, anchor, offX, offY } = this.dragging;
|
||
// Move the WHOLE group: every member keeps its exact home offset from the
|
||
// grabbed anchor, so the assembled shape rigidly translates and the knob
|
||
// geometry between members always meshes.
|
||
const bx = w.x + offX, by = w.y + offY;
|
||
for (const m of group.pieces) {
|
||
this.setPiecePos(m, bx + (m.home.x - anchor.home.x), by + (m.home.y - anchor.home.y));
|
||
}
|
||
return;
|
||
}
|
||
if (this.panning) {
|
||
const cam = this.cameras.main;
|
||
cam.scrollX = this.panning.camX - (c.x - this.panning.cx) / cam.zoom;
|
||
cam.scrollY = this.panning.camY - (c.y - this.panning.cy) / cam.zoom;
|
||
this.clampCamera();
|
||
}
|
||
}
|
||
|
||
onPointerUp() {
|
||
if (this.dragging) {
|
||
const { group, anchor } = this.dragging;
|
||
this.hideGroupGhosts(group);
|
||
this.dragging = null;
|
||
this.handleDrop(group, anchor);
|
||
}
|
||
this.panning = null;
|
||
this.time.delayedCall(0, () => { this.suppressNextButtonClick = false; });
|
||
}
|
||
|
||
// ── Drop resolution: lock onto the board, or join with table neighbours ───
|
||
// The join/lock RULES live in resolveDrop() (JigsawLogic.js, unit-checked);
|
||
// this method only applies the decided positions and does scene bookkeeping.
|
||
handleDrop(group, anchor) {
|
||
const res = resolveDrop(this.jig, group, (r, c) => this.pieceGrid[r][c], this.cell * SNAP_FRAC);
|
||
for (const pl of res.placements) this.setPiecePos(pl.piece, pl.x, pl.y);
|
||
|
||
if (res.outcome === 'locked') {
|
||
this.lockGroup(group);
|
||
return;
|
||
}
|
||
if (res.outcome === 'joined') {
|
||
// Merge the absorbed groups into the dropped one (pieces keep their exact
|
||
// mesh positions; membership is the only scene-side state change).
|
||
for (const g of res.absorbedGroups) {
|
||
this.groups.delete(g);
|
||
for (const x of g.pieces) {
|
||
if (x.group === group) continue;
|
||
x.group = group;
|
||
group.pieces.push(x);
|
||
}
|
||
}
|
||
res.placements.forEach((pl) => this.nudge(pl.piece.img));
|
||
playSound(this, SFX.UI_CHIME);
|
||
return;
|
||
}
|
||
// REST — the group just settles where it was dropped.
|
||
this.nudge(anchor.img);
|
||
playSound(this, SFX.PIECE_CLICK);
|
||
}
|
||
|
||
// Lock the group onto the board (positions already applied by handleDrop) and
|
||
// retire it from the table. `placed` advances by the group size.
|
||
lockGroup(group) {
|
||
const locked = [];
|
||
for (const m of group.pieces) {
|
||
if (m.placed) continue;
|
||
m.placed = true;
|
||
m.img.setDepth(12);
|
||
locked.push(m);
|
||
}
|
||
this.groups.delete(group);
|
||
this.placed += locked.length;
|
||
locked.forEach((m) => this.nudge(m.img));
|
||
playSound(this, this.placed === this.total ? SFX.VICTORY_SHORT : SFX.UI_PLACE);
|
||
this.updateStats();
|
||
if (this.placed === this.total) this.onWin();
|
||
}
|
||
|
||
// ── Home-slot ghosts (Easy only) ───────────────────────────────────────────
|
||
// Faint preview of a piece's home slot, shown while its group is dragged.
|
||
// Created lazily per piece so a group of N pieces shows N ghosts.
|
||
ghostFor(p) {
|
||
if (!p.ghost) {
|
||
const g = this.add.image(0, 0, p.key).setOrigin(0.5).setAlpha(0.22).setDepth(9000);
|
||
if (this.hudCam) g.cameraFilter = this.hudCam.id; // world camera only
|
||
p.ghost = g;
|
||
}
|
||
return p.ghost;
|
||
}
|
||
|
||
showGroupGhosts(group) {
|
||
if (this.difficulty !== 'easy') return; // keep the hint to Easy, as before
|
||
for (const m of group.pieces) this.ghostFor(m).setPosition(m.home.x, m.home.y).setVisible(true);
|
||
}
|
||
|
||
hideGroupGhosts(group) {
|
||
for (const m of group.pieces) if (m.ghost) m.ghost.setVisible(false);
|
||
}
|
||
|
||
nudge(obj) {
|
||
const t = obj.scale;
|
||
obj.setScale(obj.scale * 1.12);
|
||
this.tweens.add({ targets: obj, scale: t, duration: 150, ease: 'Quad.easeOut' });
|
||
}
|
||
|
||
updateStats() {
|
||
if (!this.stats) return;
|
||
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
|
||
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
|
||
this.stats.setText(`${this.placed} / ${this.total} · ${mm}:${ss}`);
|
||
if (this.diffBadge) this.diffBadge.setText(`${this.diffLabel || 'Easy'}`);
|
||
}
|
||
|
||
// ── Camera: pan / zoom ─────────────────────────────────────────────────────
|
||
clampCamera() {
|
||
const cam = this.cameras.main;
|
||
const vw = GAME_WIDTH / cam.zoom, vh = GAME_HEIGHT / cam.zoom;
|
||
cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, Math.max(0, WORLD_W - vw));
|
||
// Top bound = HUD_H so the play field never slides under the fixed top bar.
|
||
cam.scrollY = Phaser.Math.Clamp(cam.scrollY, HUD_H, Math.max(HUD_H, WORLD_H - vh));
|
||
}
|
||
|
||
// Zoom about the point (cx, cy) in canvas space: the world point under that
|
||
// screen position stays fixed, so zooming in/out is anchored to the cursor
|
||
// (relative to the board) rather than snapping to the canvas centre.
|
||
zoomAt(cx, cy, factor) {
|
||
const cam = this.cameras.main;
|
||
const oldZ = cam.zoom;
|
||
const newZ = Phaser.Math.Clamp(oldZ * factor, MIN_ZOOM, MAX_ZOOM);
|
||
if (Math.abs(newZ - oldZ) < 0.0001) return;
|
||
const wx = cam.scrollX + cx / oldZ;
|
||
const wy = cam.scrollY + cy / oldZ;
|
||
cam.setZoom(newZ);
|
||
cam.scrollX = wx - cx / newZ;
|
||
cam.scrollY = wy - cy / newZ;
|
||
this.clampCamera();
|
||
}
|
||
|
||
zoomStep(factor) {
|
||
this.zoomAt(GAME_WIDTH / 2, GAME_HEIGHT / 2, factor);
|
||
}
|
||
|
||
resetView() { this.setZoomTo(1); }
|
||
|
||
// Menu/loading views live in canvas space (top-left of the world).
|
||
resetToCanvas() {
|
||
const cam = this.cameras.main;
|
||
cam.setZoom(1);
|
||
cam.setScroll(0, HUD_H); // top bound so the field never sits under the HUD
|
||
}
|
||
|
||
setZoomTo(z) {
|
||
const cam = this.cameras.main;
|
||
cam.setZoom(Phaser.Math.Clamp(z, MIN_ZOOM, MAX_ZOOM));
|
||
// Centre the field on the board so the start view frames the puzzle.
|
||
cam.centerOn(BOARD.x + BOARD.size / 2, BOARD.y + BOARD.size / 2);
|
||
this.clampCamera();
|
||
}
|
||
|
||
pinHUD() {
|
||
const cam = this.cameras.main;
|
||
// The HUD and the win overlay are both pinned to the top-left of the
|
||
// viewport (scaled to counter the zoom) so they sit in screen space no
|
||
// matter where the camera is panned/zoomed within the (bigger) field.
|
||
if (this.hud) {
|
||
this.hud.setPosition(cam.scrollX, cam.scrollY);
|
||
this.hud.setScale(1 / cam.zoom);
|
||
}
|
||
if (this.winLayer) {
|
||
this.winLayer.setPosition(cam.scrollX, cam.scrollY);
|
||
this.winLayer.setScale(1 / cam.zoom);
|
||
}
|
||
}
|
||
|
||
// ── State flow ─────────────────────────────────────────────────────────────
|
||
toggleHint() {
|
||
this.hintOn = !this.hintOn;
|
||
if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.2 : 0);
|
||
this.btnHint.setActive(this.hintOn);
|
||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||
}
|
||
|
||
restart() {
|
||
if (this.state === 'menu') return;
|
||
this.beginPlay(this.currentImage());
|
||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||
}
|
||
|
||
toMenu() {
|
||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||
this.teardownPlay();
|
||
this.setState('menu');
|
||
this.loadPreview(this.currentImage());
|
||
}
|
||
|
||
onWin() {
|
||
this.state = 'won';
|
||
this.setHUDForState('won');
|
||
playSound(this, SFX.VICTORY_SHORT);
|
||
this.showWinOverlay();
|
||
}
|
||
|
||
showWinOverlay() {
|
||
this.teardownWin();
|
||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||
this.winLayer = this.add.container(0, 0).setDepth(9600);
|
||
if (this.mainCam) this.winLayer.cameraFilter = this.mainCam.id; // fixed hudCam only
|
||
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55);
|
||
const panel = this.add.graphics();
|
||
panel.fillStyle(0x17130c, 0.98).fillRoundedRect(cx - 310, cy - 210, 620, 420, 20);
|
||
panel.lineStyle(3, COLORS.gold, 0.9).strokeRoundedRect(cx - 310, cy - 210, 620, 420, 20);
|
||
const T = (y, s, color, size) => {
|
||
const t = this.add.text(cx, y, s, { fontFamily: '"Julius Sans One"', fontSize: size, color, letterSpacing: 2 }).setOrigin(0.5);
|
||
this.winLayer.add(t);
|
||
return t;
|
||
};
|
||
this.winLayer.add([dim, panel]);
|
||
T(cy - 138, 'PUZZLE COMPLETE', COLORS.goldHex, '44px');
|
||
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
|
||
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
|
||
T(cy - 70, `Solved in ${mm}:${ss}`, COLORS.textHex, '28px');
|
||
T(cy - 24, `${this.diffLabel || ''} · ${this.imageName || ''}`, COLORS.mutedHex, '20px');
|
||
const again = this.mkButton(this.winLayer, 'Play Again', cx - 120, cy + 100, 210, 64, () => this.restart(), { fontSize: 26, bg: COLORS.gold });
|
||
this.mkButton(this.winLayer, 'Menu', cx + 120, cy + 100, 210, 64, () => this.toMenu(), { fontSize: 26 });
|
||
panel.setScale(0.85);
|
||
this.tweens.add({ targets: panel, scale: 1, duration: 280, ease: 'Back.easeOut' });
|
||
this.winObjects = [this.winLayer];
|
||
}
|
||
|
||
teardownWin() {
|
||
if (this.winObjects) {
|
||
this.winObjects.forEach((o) => o.destroy());
|
||
this.winObjects = null;
|
||
this.winLayer = null;
|
||
}
|
||
}
|
||
|
||
teardownPlay() {
|
||
this.teardownWin();
|
||
this.dragging = null; this.panning = null;
|
||
if (this.boardPanel) this.boardPanel.destroy();
|
||
if (this.boardFrame) this.boardFrame.destroy();
|
||
if (this.refImage) this.refImage.destroy();
|
||
(this.pieces || []).forEach((p) => {
|
||
p.img.destroy();
|
||
if (p.ghost) p.ghost.destroy();
|
||
if (this.textures.exists(p.key)) this.textures.remove(p.key);
|
||
});
|
||
this.pieces = [];
|
||
this.groups = new Set();
|
||
this.pieceGrid = [];
|
||
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
|
||
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
|
||
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
|
||
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
|
||
}
|
||
|
||
setBoardVisible(v) {
|
||
[this.boardPanel, this.boardFrame, this.refImage].forEach((o) => o && o.setVisible(v));
|
||
(this.pieces || []).forEach((p) => { p.img.setVisible(v); if (p.ghost) p.ghost.setVisible(false); });
|
||
}
|
||
|
||
// ── Loop ───────────────────────────────────────────────────────────────────
|
||
update(time) {
|
||
if (this.state === 'playing') {
|
||
if (!this.startTime) this.startTime = time;
|
||
this.elapsed = Math.max(0, (time - this.startTime) / 1000);
|
||
this.updateStats();
|
||
}
|
||
}
|
||
}
|