Redesign jigsaw win screen with zoomed board view and celebration effect

- Frame the completed puzzle large on the right while parking stats/actions in a left-hand panel, easing the camera into place
- Add staggered slide-in choreography for title, time, stats (with piece counter), and action buttons
- Fire confetti burst + falling curtain, a shine sweep, and a pulsing gold frame when the win camera lands
- Introduce exitWin() so restart/menu pull the camera back and slide the panel out before rebuilding state
- Simplify pinHUD() now that HUD/win overlay render through the fixed hudCam
This commit is contained in:
Brian Fertig 2026-08-31 08:11:27 -06:00
parent 243d2e143c
commit 18d89d4553
1 changed files with 284 additions and 34 deletions

View File

@ -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
@ -1089,18 +1125,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 +1142,51 @@ export default class JigsawGame extends Phaser.Scene {
restart() {
if (this.state === 'menu') return;
this.beginPlay(this.currentImage());
playSound(this, SFX.EIGHTBIT_ACTIVATE);
// 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.teardownPlay();
this.setState('menu');
this.loadPreview(this.currentImage());
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 +1196,224 @@ 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 — this lands
// the board vertically centred on screen, its top just under the HUD 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 - (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.parent) 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() {