jigsaw-ui-improvements #7
|
|
@ -0,0 +1,5 @@
|
|||
# Agent notes
|
||||
|
||||
## Git
|
||||
- **The user makes their own commits.** Leave changes in the working tree; do not
|
||||
run `git commit` (or `git push`) unless explicitly asked.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
{
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "fertig-classic-games",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,5 +9,8 @@
|
|||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"devDependencies": {
|
||||
"playwright": "^1.62.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,12 @@ const SNAP_FRAC = 0.42; // snap radius (×cell) — used for both locking a
|
|||
// 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
|
||||
|
||||
// Win view: the finished board is framed large on the RIGHT of the screen
|
||||
// (near full-screen), with the completion info panel parked on the LEFT out
|
||||
// of the way of the picture. The camera eases into the framing.
|
||||
const WIN_ZOOM = 1.24; // board (700px) renders ≈868px tall
|
||||
const WIN_PANEL = { x: 48, y: 110, w: 470, h: 860 };
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
|
|
@ -166,6 +172,35 @@ function makePocketTexture(size) {
|
|||
return cv;
|
||||
}
|
||||
|
||||
// Confetti bit: a small rounded square (the emitter tints each particle).
|
||||
function makeConfettiTexture() {
|
||||
const S = 18;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = S; cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
roundRectPath(ctx, 2, 2, S - 4, S - 4, 4);
|
||||
ctx.fillStyle = '#f2ead8';
|
||||
ctx.fill();
|
||||
return cv;
|
||||
}
|
||||
|
||||
// Diagonal light band for the shine sweep across the finished puzzle.
|
||||
function makeShineTexture() {
|
||||
const W = 256, H = 256;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = W; cv.height = H;
|
||||
const ctx = cv.getContext('2d');
|
||||
const g = ctx.createLinearGradient(0, 0, W, 0);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0)');
|
||||
g.addColorStop(0.42, 'rgba(255,246,214,0.55)');
|
||||
g.addColorStop(0.5, 'rgba(255,250,228,0.9)');
|
||||
g.addColorStop(0.58, 'rgba(255,246,214,0.55)');
|
||||
g.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
return cv;
|
||||
}
|
||||
|
||||
function offsetOutline(o, ox, oy) {
|
||||
return {
|
||||
start: { x: o.start.x + ox, y: o.start.y + oy },
|
||||
|
|
@ -213,6 +248,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
this.elapsed = 0;
|
||||
this.hintOn = true;
|
||||
this.selectedDiff = 'easy';
|
||||
this.winTransition = false;
|
||||
|
||||
this.loadArtwork();
|
||||
// Open the menu on a random picture, not always the first one, so each
|
||||
|
|
@ -478,7 +514,7 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
|
||||
buttonHit(cx, cy) {
|
||||
for (const b of this.buttons) {
|
||||
if (!b.visible || !b.parent || !b.parent.visible) continue;
|
||||
if (!b.visible || (b.parentContainer && !b.parentContainer.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;
|
||||
}
|
||||
|
|
@ -769,8 +805,13 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
// 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 };
|
||||
// but at full zoom-out the camera is clamped to scrollY = HUD_H, so the
|
||||
// fixed bar still covers world y ∈ [HUD_H, HUD_H + HUD_H/MIN_ZOOM]. Push
|
||||
// the tray top down so the WHOLE piece — base cell + tabs + shadow
|
||||
// headroom, not just its centre — stays below that band.
|
||||
const pieceHalf = this.cell + Math.max(7, this.cell * 0.09); // matches buildPieceCanvas PAD
|
||||
const trayTop = HUD_H + HUD_H / MIN_ZOOM + pieceHalf;
|
||||
const tray = { x: 40, y: trayTop, w: WORLD_W - 80, h: WORLD_H - trayTop - 40 };
|
||||
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
|
||||
|
|
@ -1089,18 +1130,11 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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);
|
||||
}
|
||||
// NOTE: the HUD bar, the Menu ▾ dropdown and the win overlay are all
|
||||
// rendered by the fixed hudCam (see create()), which already places them
|
||||
// in screen space — no pinning required. Kept as a no-op guard so nothing
|
||||
// accidentally re-positions them. (buttonHit() relies on the same
|
||||
// screen-space assumption.)
|
||||
}
|
||||
|
||||
// ── State flow ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -1113,15 +1147,51 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
|
||||
restart() {
|
||||
if (this.state === 'menu') return;
|
||||
// Pull the camera back to the start framing first, then rebuild the board.
|
||||
const startCamX = BOARD.x + BOARD.size / 2 - GAME_WIDTH / 2; // matches setZoomTo(1)
|
||||
const startCamY = BOARD.y + BOARD.size / 2 - GAME_HEIGHT / 2; // matches setZoomTo(1)
|
||||
this.exitWin(() => {
|
||||
this.beginPlay(this.currentImage());
|
||||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||||
}, startCamX, startCamY);
|
||||
}
|
||||
|
||||
toMenu() {
|
||||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||||
this.exitWin(() => {
|
||||
this.teardownPlay();
|
||||
this.setState('menu');
|
||||
this.loadPreview(this.currentImage());
|
||||
}, 0, HUD_H);
|
||||
}
|
||||
|
||||
// Exit the win view: panel slides out, camera pulls back, then run `done()`.
|
||||
exitWin(done, targetScrollX, targetScrollY) {
|
||||
if (this.state !== 'won' || !this.winLayer) { done(); return; }
|
||||
if (this.winTransition) return;
|
||||
this.winTransition = true;
|
||||
// Kill any in-flight showcase tweens (slide-ins, camera sweep, counter) so
|
||||
// they don't fight the exit animation or fire the celebration late.
|
||||
(this.winTweens || []).forEach((t) => t && t.stop());
|
||||
this.winTweens = [];
|
||||
|
||||
playSound(this, SFX.VEGA_ZOOMOUT);
|
||||
(this.winPanelGroups || []).forEach((g, i) => {
|
||||
this.winTweens.push(this.tweens.add({ targets: g, x: g.x - 420, alpha: 0, delay: i * 45, duration: 300, ease: 'Cubic.easeIn' }));
|
||||
});
|
||||
if (this.winDim && this.winPanelBg) {
|
||||
this.winTweens.push(this.tweens.add({ targets: [this.winDim, this.winPanelBg], alpha: 0, duration: 330, ease: 'Cubic.easeIn' }));
|
||||
}
|
||||
const cam = this.cameras.main;
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: cam, scrollX: targetScrollX, scrollY: targetScrollY, zoom: 1,
|
||||
duration: 460, ease: 'Cubic.easeInOut',
|
||||
onComplete: () => {
|
||||
this.winTransition = false;
|
||||
this.teardownWin();
|
||||
done();
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
onWin() {
|
||||
|
|
@ -1131,39 +1201,225 @@ export default class JigsawGame extends Phaser.Scene {
|
|||
this.showWinOverlay();
|
||||
}
|
||||
|
||||
// ── Win showcase ────────────────────────────────────────────────────────
|
||||
// The finished board eases into a near-full-screen frame on the RIGHT of the
|
||||
// screen; the completion info + actions settle into a left-hand panel out of
|
||||
// the way of the picture. Then it celebrates: confetti burst + falling
|
||||
// curtain, a shine sweep across the picture, and a pulsing gold frame.
|
||||
showWinOverlay() {
|
||||
this.teardownWin();
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
this.winTweens = [];
|
||||
this.winEmitters = [];
|
||||
this.winPanelGroups = [];
|
||||
|
||||
if (!this.textures.exists('jigsaw-confetti')) this.textures.addCanvas('jigsaw-confetti', makeConfettiTexture());
|
||||
if (!this.textures.exists('jigsaw-shine')) this.textures.addCanvas('jigsaw-shine', makeShineTexture());
|
||||
|
||||
// Win camera: board centred in the region right of the info panel. The
|
||||
// board sits mid-world, so the camera must travel to its row. Vertically
|
||||
// it is centred in the area BELOW the fixed HUD bar (not the whole screen),
|
||||
// so the framed picture has comfortable breathing room under the bar.
|
||||
const bcx = BOARD.x + BOARD.size / 2;
|
||||
const bcy = BOARD.y + BOARD.size / 2;
|
||||
const boardCx = (WIN_PANEL.x + WIN_PANEL.w + GAME_WIDTH) / 2;
|
||||
const winScrollX = bcx - boardCx / WIN_ZOOM;
|
||||
const winScrollY = bcy - ((HUD_H + GAME_HEIGHT) / 2) / WIN_ZOOM;
|
||||
|
||||
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);
|
||||
|
||||
const T = (target, x, y, s, color, o = {}) => {
|
||||
const t = this.add.text(x, y, s, {
|
||||
fontFamily: '"Julius Sans One"', fontSize: o.size || '20px', color, ...o,
|
||||
}).setOrigin(o.align === 'left' ? 0 : 0.5);
|
||||
target.add(t);
|
||||
return t;
|
||||
};
|
||||
this.winLayer.add([dim, panel]);
|
||||
T(cy - 138, 'PUZZLE COMPLETE', COLORS.goldHex, '44px');
|
||||
|
||||
// Subtle dim — the picture stays the hero, the panel gets its contrast.
|
||||
const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.30);
|
||||
dim.setAlpha(0);
|
||||
this.winDim = dim;
|
||||
|
||||
const { x: PX, y: PY, w: PW } = WIN_PANEL;
|
||||
const panelBg = this.add.graphics();
|
||||
panelBg.fillStyle(0x17130c, 0.97).fillRoundedRect(PX, PY, PW, WIN_PANEL.h, 20);
|
||||
panelBg.lineStyle(2, COLORS.accent, 0.55).strokeRoundedRect(PX, PY, PW, WIN_PANEL.h, 20);
|
||||
panelBg.fillStyle(COLORS.gold, 0.95).fillRoundedRect(PX + 42, PY + 48, 64, 4, 2);
|
||||
panelBg.setAlpha(0);
|
||||
this.winPanelBg = panelBg;
|
||||
this.winLayer.add([dim, panelBg]);
|
||||
|
||||
const LX = PX + 42; // left-aligned text edge
|
||||
const BCX = PX + PW / 2, BW = PW - 84; // action-button geometry
|
||||
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' });
|
||||
|
||||
// Row 1 — title + image name
|
||||
const gTitle = this.add.container(0, 0);
|
||||
this.winLayer.add(gTitle);
|
||||
T(gTitle, LX, PY + 106, 'PUZZLE COMPLETE', COLORS.goldHex, { size: '34px', letterSpacing: 4, align: 'left' });
|
||||
T(gTitle, LX, PY + 150, this.imageName || 'Puzzle', COLORS.textHex, { size: '20px', align: 'left' });
|
||||
|
||||
// Row 2 — solve time
|
||||
const gTime = this.add.container(0, 0);
|
||||
this.winLayer.add(gTime);
|
||||
T(gTime, LX, PY + 232, 'SOLVED IN', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
|
||||
T(gTime, LX, PY + 280, `${mm}:${ss}`, COLORS.textHex, { size: '56px', align: 'left' });
|
||||
|
||||
// Row 3 — pieces + difficulty
|
||||
const gStats = this.add.container(0, 0);
|
||||
this.winLayer.add(gStats);
|
||||
const rule = this.add.graphics();
|
||||
rule.lineStyle(1, 0x3a3226, 0.9);
|
||||
rule.lineBetween(LX, PY + 344, LX + (PW - 84), PY + 344);
|
||||
gStats.add(rule);
|
||||
T(gStats, LX, PY + 384, 'PIECES', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
|
||||
this.piecesBig = T(gStats, LX, PY + 424, '0', COLORS.textHex, { size: '30px', align: 'left' });
|
||||
const dx = PX + PW / 2 + 20;
|
||||
const diffName = (DIFFICULTIES[this.difficulty] && DIFFICULTIES[this.difficulty].label) || '—';
|
||||
T(gStats, dx, PY + 384, 'DIFFICULTY', COLORS.mutedHex, { size: '15px', letterSpacing: 4, align: 'left' });
|
||||
T(gStats, dx, PY + 424, diffName, COLORS.textHex, { size: '22px', align: 'left' });
|
||||
|
||||
// Row 4 — actions
|
||||
const gBtns = this.add.container(0, 0);
|
||||
this.winLayer.add(gBtns);
|
||||
this.mkButton(gBtns, 'Play Again', BCX, PY + 512, BW, 62, () => this.restart(), { fontSize: 24, bg: COLORS.gold });
|
||||
this.mkButton(gBtns, 'Menu', BCX, PY + 594, BW, 62, () => this.toMenu(), { fontSize: 24 });
|
||||
this.mkButton(gBtns, '🎲 Surprise Me', BCX, PY + 676, BW, 62, () => { this.randomImage(); this.restart(); }, { fontSize: 22, variant: 'ghost' });
|
||||
T(gBtns, BCX, PY + 782, 'The picture is complete — enjoy the view.', COLORS.mutedHex, { size: '16px' });
|
||||
|
||||
// ── Choreography ─────────────────────────────────────────────────────
|
||||
const stagger = (g, delay) => {
|
||||
g.setAlpha(0);
|
||||
const fx = g.x;
|
||||
g.x = fx - 46; // slide in from the left
|
||||
this.winTweens.push(this.tweens.add({ targets: g, x: fx, alpha: 1, delay, duration: 480, ease: 'Cubic.easeOut' }));
|
||||
this.winPanelGroups.push(g);
|
||||
};
|
||||
|
||||
this.winTweens.push(this.tweens.add({ targets: [dim, panelBg], alpha: 1, delay: 350, duration: 550, ease: 'Cubic.easeOut' }));
|
||||
stagger(gTitle, 500);
|
||||
stagger(gTime, 620);
|
||||
stagger(gStats, 740);
|
||||
stagger(gBtns, 860);
|
||||
|
||||
// Piece counter counts up as the stats row lands.
|
||||
const counter = { v: 0 };
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: counter, v: this.total, delay: 760, duration: 950, ease: 'Quad.easeOut',
|
||||
onUpdate: (tw) => { if (this.piecesBig) this.piecesBig.setText(String(Math.round(tw.getValue(0)))); },
|
||||
}));
|
||||
|
||||
// Camera sweep — the finale fires when it lands.
|
||||
playSound(this, SFX.VEGA_ZOOMIN);
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: this.cameras.main,
|
||||
scrollX: winScrollX, scrollY: winScrollY, zoom: WIN_ZOOM,
|
||||
duration: 1200, ease: 'Cubic.easeInOut',
|
||||
onComplete: () => this.winCelebrate(),
|
||||
}));
|
||||
this.winObjects = [this.winLayer];
|
||||
}
|
||||
|
||||
// Fired when the win camera lands: confetti, shine sweep, gold frame pulse.
|
||||
winCelebrate() {
|
||||
if (!this.winLayer || this.state !== 'won') return;
|
||||
const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2;
|
||||
const TINTS = [0xd4a017, 0xf2ead8, 0xffffff, 0xc8a84b, 0xe06c75];
|
||||
|
||||
playSound(this, SFX.FIREWORK);
|
||||
playSound(this, SFX.EIGHTBIT_WIN);
|
||||
|
||||
// Burst from the heart of the finished puzzle.
|
||||
const burst = this.add.particles(bcx, bcy, 'jigsaw-confetti', {
|
||||
radial: true,
|
||||
lifespan: { min: 1200, max: 2400 },
|
||||
speed: { min: 220, max: 760 },
|
||||
angle: { min: 0, max: 360 },
|
||||
gravityY: 950,
|
||||
rotate: { start: -720, end: 720, random: true },
|
||||
scaleX: { min: 0.45, max: 1.25 },
|
||||
scaleY: { min: 0.45, max: 1.25 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
tint: TINTS,
|
||||
maxAliveParticles: 300,
|
||||
});
|
||||
if (this.hudCam) burst.cameraFilter = this.hudCam.id; // world camera only
|
||||
burst.setDepth(50); // above the finished pieces (depth 10-12)
|
||||
burst.explode(120);
|
||||
this.winEmitters.push(burst);
|
||||
|
||||
// Curtain of confetti falling across the whole frame (screen-anchored).
|
||||
const cam = this.cameras.main;
|
||||
const band = new Phaser.Geom.Rectangle(cam.scrollX, cam.scrollY, GAME_WIDTH / cam.zoom, 120);
|
||||
const curtain = this.add.particles(0, 0, 'jigsaw-confetti', {
|
||||
lifespan: { min: 1700, max: 3200 },
|
||||
speedX: { min: -70, max: 70 },
|
||||
speedY: { min: 90, max: 260 },
|
||||
gravityY: 430,
|
||||
rotate: { start: -540, end: 540, random: true },
|
||||
scaleX: { min: 0.4, max: 1.05 },
|
||||
scaleY: { min: 0.4, max: 1.05 },
|
||||
alpha: { start: 1, end: 0 },
|
||||
tint: TINTS,
|
||||
emitZone: { type: 'random', source: band },
|
||||
maxAliveParticles: 220,
|
||||
});
|
||||
if (this.hudCam) curtain.cameraFilter = this.hudCam.id;
|
||||
curtain.setDepth(50); // above the finished pieces (depth 10-12)
|
||||
curtain.explode(90);
|
||||
this.winEmitters.push(curtain);
|
||||
|
||||
// Gold trophy frame around the board, then a gentle pulse.
|
||||
const frame = this.add.graphics().setDepth(4);
|
||||
if (this.hudCam) frame.cameraFilter = this.hudCam.id; // world camera only
|
||||
frame.lineStyle(3, COLORS.gold, 0.95);
|
||||
frame.strokeRoundedRect(BOARD.x - 16, BOARD.y - 16, BOARD.size + 32, BOARD.size + 32, 18);
|
||||
frame.lineStyle(1, 0xfff1cf, 0.5);
|
||||
frame.strokeRoundedRect(BOARD.x - 7, BOARD.y - 7, BOARD.size + 14, BOARD.size + 14, 12);
|
||||
frame.setAlpha(0);
|
||||
this.winFrame = frame;
|
||||
this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.95, duration: 600, ease: 'Cubic.easeOut' }));
|
||||
this.winTweens.push(this.tweens.add({ targets: frame, alpha: 0.62, duration: 1500, delay: 700, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }));
|
||||
|
||||
// Shine sweep across the completed picture.
|
||||
const shine = this.add.image(bcx, bcy, 'jigsaw-shine')
|
||||
.setDisplaySize(BOARD.size * 0.55, BOARD.size * 1.9)
|
||||
.setRotation(-0.22)
|
||||
.setAlpha(0)
|
||||
.setDepth(40)
|
||||
.setBlendMode(Phaser.BlendModes.ADD);
|
||||
if (this.hudCam) shine.cameraFilter = this.hudCam.id; // world camera only
|
||||
this.winShine = shine;
|
||||
const x0 = bcx - BOARD.size * 1.05, x1 = bcx + BOARD.size * 1.05;
|
||||
shine.x = x0; // start off the left edge of the board, sweep through to the right
|
||||
this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.5, duration: 450, ease: 'Cubic.easeIn' }));
|
||||
this.winTweens.push(this.tweens.add({ targets: shine, x: bcx, alpha: 0.55, duration: 350, delay: 450, ease: 'Sine.easeInOut' }));
|
||||
this.winTweens.push(this.tweens.add({
|
||||
targets: shine, x: x1, alpha: 0, duration: 520, delay: 800, ease: 'Cubic.easeOut',
|
||||
onComplete: () => shine.setVisible(false),
|
||||
}));
|
||||
}
|
||||
|
||||
teardownWin() {
|
||||
(this.winTweens || []).forEach((t) => t && t.stop());
|
||||
this.winTweens = null;
|
||||
(this.winEmitters || []).forEach((e) => e && e.destroy());
|
||||
this.winEmitters = null;
|
||||
this.winPanelGroups = null;
|
||||
this.piecesBig = null;
|
||||
if (this.winFrame) { this.winFrame.destroy(); this.winFrame = null; }
|
||||
if (this.winShine) { this.winShine.destroy(); this.winShine = null; }
|
||||
if (this.winObjects) {
|
||||
this.winObjects.forEach((o) => o.destroy());
|
||||
this.winObjects = null;
|
||||
this.winLayer = null;
|
||||
}
|
||||
this.winLayer = null;
|
||||
this.winDim = null;
|
||||
this.winPanelBg = null;
|
||||
if (this.textures.exists('jigsaw-confetti')) this.textures.remove('jigsaw-confetti');
|
||||
if (this.textures.exists('jigsaw-shine')) this.textures.remove('jigsaw-shine');
|
||||
}
|
||||
|
||||
teardownPlay() {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,162 @@
|
|||
// Browser smoke test for the Jigsaw game (Playwright + headless Chromium).
|
||||
//
|
||||
// Usage:
|
||||
// npx playwright install chromium # one-time
|
||||
// python3 -m http.server 8000 # serve the repo root (or pass a base URL)
|
||||
// node tools/smokeJigsaw.mjs # defaults to http://127.0.0.1:8000
|
||||
// node tools/smokeJigsaw.mjs http://localhost:9000
|
||||
//
|
||||
// What it checks:
|
||||
// - game loads, menu → Start Puzzle → playing, pieces spawn on the table
|
||||
// - forced win (placed = total; onWin()) runs the full showcase:
|
||||
// * camera sweep lands on WIN_ZOOM framing the board
|
||||
// * stats counter animates up to the total
|
||||
// * celebration emitters fire (burst + curtain) and confetti is alive
|
||||
// * gold frame + shine sweep exist at the right depths
|
||||
// - "Play Again" restarts a fresh board and tears the win layer down
|
||||
// - "Menu" returns to the menu and destroys the win layer
|
||||
// - menu → Start Puzzle works again
|
||||
// - zero JS console/page errors across the whole flow
|
||||
//
|
||||
// NOTE: headless Chromium renders this 1920×1080 WebGL scene at only a few
|
||||
// FPS (SwiftShader), so the scene clock advances slowly in real time. All
|
||||
// waits here are condition-based polling (generous timeouts), never fixed
|
||||
// sleeps. A full run takes roughly 1–3 minutes of wall time.
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const BASE = process.argv[2] || 'http://127.0.0.1:8000/__jig_test.html';
|
||||
|
||||
let server = null;
|
||||
if (!process.argv[2]) {
|
||||
try {
|
||||
await fetch(new URL('..', BASE), { method: 'HEAD', signal: AbortSignal.timeout(1500) });
|
||||
} catch {
|
||||
server = spawn('python3', ['-m', 'http.server', '8000'], { cwd: ROOT, stdio: 'ignore', detached: true });
|
||||
await new Promise((r) => setTimeout(r, 1200));
|
||||
console.log('started local static server on :8000');
|
||||
}
|
||||
}
|
||||
|
||||
const errors = [];
|
||||
let failed = false;
|
||||
const ok = (m) => console.log(' ok ' + m);
|
||||
const bad = (m) => { failed = true; console.log(' FAIL ' + m); };
|
||||
|
||||
const browser = await chromium.launch({ args: ['--no-sandbox'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1920, height: 1080 } });
|
||||
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
|
||||
page.on('console', (m) => { if (m.type() === 'error') errors.push('console: ' + m.text()); });
|
||||
|
||||
const evalS = (fn, ...a) => page.evaluate(fn, ...a);
|
||||
const waitFor = async (name, cond, timeoutMs) => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeoutMs) {
|
||||
if (await evalS(cond)) return true;
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
bad(`timeout waiting for: ${name}`);
|
||||
return false;
|
||||
};
|
||||
const snap = async (name) => {
|
||||
const dest = path.join(os.tmpdir(), `jigsaw_smoke_${name}.png`);
|
||||
await page.screenshot({ path: dest, fullPage: false });
|
||||
ok(`screenshot ${dest}`);
|
||||
};
|
||||
const click = (x, y) => evalS((pt) => { window.__mouseDown(pt[0], pt[1]); window.__mouseUp(pt[0], pt[1]); }, [x, y]);
|
||||
const settleTimeout = Number(process.env.JIG_SMOKE_SETTLE_MS || 180000);
|
||||
|
||||
try {
|
||||
console.log('— load —');
|
||||
await page.goto(BASE, { waitUntil: 'load' });
|
||||
await page.waitForFunction(() => document.getElementById('log').textContent.includes('harness ready'), null, { timeout: 30000 });
|
||||
ok('harness ready');
|
||||
|
||||
console.log('— start puzzle —');
|
||||
await evalS(() => window.__startPuzzle());
|
||||
if (!(await waitFor('state=playing', () => window.__scene().state === 'playing', 60000))) throw new Error('never reached playing');
|
||||
ok('state = playing');
|
||||
const pieces = await evalS(() => window.__pieces());
|
||||
ok(`pieces on table: ${pieces.length} (placed=${pieces.filter((p) => p.placed).length})`);
|
||||
if (pieces.length === 0) bad('no pieces spawned');
|
||||
await snap('1_playing');
|
||||
|
||||
console.log('— force win —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
ok('onWin() called (state=' + (await evalS(() => window.__scene().state)) + ')');
|
||||
|
||||
console.log('— wait for panel settle (camera at WIN_ZOOM, counter done) —');
|
||||
if (await waitFor('camera settled + counter=total', () => {
|
||||
const s = window.__scene();
|
||||
return s.state === 'won' && Math.abs(s.cameras.main.zoom - 1.24) < 0.01
|
||||
&& s.piecesBig && s.piecesBig.text === String(s.total);
|
||||
}, settleTimeout)) {
|
||||
const wi = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { zoom: +s.cameras.main.zoom.toFixed(3), sx: Math.round(s.cameras.main.scrollX), sy: Math.round(s.cameras.main.scrollY), counter: s.piecesBig.text };
|
||||
});
|
||||
ok('settled: ' + JSON.stringify(wi));
|
||||
await snap('2_win_panel');
|
||||
}
|
||||
|
||||
console.log('— wait for celebration (emitters created + particles alive) —');
|
||||
if (await waitFor('emitters alive', () => {
|
||||
const s = window.__scene();
|
||||
return (s.winEmitters || []).length === 2 && s.winEmitters.every((e) => e.getAliveParticleCount() > 0);
|
||||
}, settleTimeout)) {
|
||||
const alive = await evalS(() => window.__scene().winEmitters.map((e) => e.getAliveParticleCount()));
|
||||
ok('particles alive: ' + JSON.stringify(alive));
|
||||
await snap('3_confetti');
|
||||
const fx = await evalS(() => {
|
||||
const s = window.__scene();
|
||||
return { frame: !!s.winFrame, shine: !!s.winShine, frameDepth: s.winFrame && s.winFrame.depth, shineDepth: s.winShine && s.winShine.depth };
|
||||
});
|
||||
ok('frame/shine: ' + JSON.stringify(fx));
|
||||
if (!fx.frame || !fx.shine) bad('winFrame or winShine missing');
|
||||
}
|
||||
|
||||
console.log('— Play Again button —');
|
||||
await click(400, 640); // inside "Play Again" (visual 90..476 × 591..653, hit rect 283..669 × 622..684)
|
||||
if (await waitFor('state=playing after Play Again', () => window.__scene().state === 'playing', 120000)) {
|
||||
const placed = await evalS(() => window.__scene().pieces.filter((p) => p.placed).length);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`restarted: placed=${placed}, winLayer destroyed=${winGone}`);
|
||||
if (placed !== 0) bad('pieces not reset after Play Again');
|
||||
if (!winGone) bad('winLayer not destroyed after Play Again');
|
||||
await snap('4_restarted');
|
||||
}
|
||||
|
||||
console.log('— force win again, then Menu button —');
|
||||
await evalS(() => { const s = window.__scene(); s.placed = s.total; s.onWin(); });
|
||||
if (await waitFor('second showcase emitters', () => (window.__scene().winEmitters || []).length === 2, settleTimeout)) {
|
||||
ok('second win showcase running');
|
||||
await click(400, 720); // inside "Menu"
|
||||
if (await waitFor('state=menu after Menu', () => window.__scene().state === 'menu', 120000)) {
|
||||
const menuVisible = await evalS(() => window.__scene().menu && window.__scene().menu.visible);
|
||||
const winGone = await evalS(() => !window.__scene().winLayer);
|
||||
ok(`back to menu: menu.visible=${menuVisible}, winLayer destroyed=${winGone}`);
|
||||
if (!menuVisible) bad('menu not visible');
|
||||
if (!winGone) bad('winLayer not destroyed');
|
||||
await snap('5_menu');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('— Start Puzzle from menu works —');
|
||||
await evalS(() => { const s = window.__scene(); s.selectedDiff = 'easy'; s.startPuzzle(); });
|
||||
if (await waitFor('state=playing from menu', () => window.__scene().state === 'playing', 60000)) ok('menu → playing OK');
|
||||
} finally {
|
||||
await browser.close();
|
||||
if (server) { try { process.kill(-server.pid); } catch { /* already gone */ } }
|
||||
}
|
||||
|
||||
console.log('\n— JS errors (' + errors.length + ') —');
|
||||
errors.forEach((e) => console.log(' ' + e));
|
||||
if (errors.length) failed = true;
|
||||
|
||||
console.log(failed ? '\nSMOKE TEST: FAILED' : '\nSMOKE TEST: ALL PASS');
|
||||
process.exit(failed ? 1 : 0);
|
||||
Loading…
Reference in New Issue