diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js index 29b3863..fda1a40 100644 --- a/src/games/jigsaw/JigsawGame.js +++ b/src/games/jigsaw/JigsawGame.js @@ -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() {