From 22e6b68ceff80785e040a65d2df5cd82c9e831e9 Mon Sep 17 00:00:00 2001 From: Brian Fertig Date: Sun, 30 Aug 2026 18:24:36 -0600 Subject: [PATCH] Add camera pan/zoom and fixed HUD for jigsaw puzzle - Expand the play field to a 3840x2160 world so pieces can scatter widely and the player can explore - Introduce MIN_ZOOM (0.5) so the entire field fits on screen, with zoom anchored to the cursor - Add a second fixed HUD camera so the menu, stats bar, and win overlay stay in screen space while the board camera pans/zooms - Route world objects and UI containers to their respective cameras via cameraFilter - Update background, scatter tray, and camera clamping to use the new world dimensions --- src/games/jigsaw/JigsawGame.js | 101 +++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 23 deletions(-) diff --git a/src/games/jigsaw/JigsawGame.js b/src/games/jigsaw/JigsawGame.js index 84edd7c..b2c01c2 100644 --- a/src/games/jigsaw/JigsawGame.js +++ b/src/games/jigsaw/JigsawGame.js @@ -14,8 +14,14 @@ import { // ───────────────────────────────────────────────────────────────────────────── const HUD_H = 88; const BOARD_SIZE = 700; -const BOARD = { x: (GAME_WIDTH - BOARD_SIZE) / 2, y: 150, size: BOARD_SIZE }; +// 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 as a fraction of the cell size const GRAB_FRAC = 0.65; // grab radius (×cell): covers the piece body incl. most knobs @@ -69,12 +75,37 @@ export default class JigsawGame extends Phaser.Scene { this.buildMenu(); this.bindInput(); - this.cameras.main.setBounds(0, 0, GAME_WIDTH, GAME_HEIGHT); + this.cameras.main.setBounds(0, 0, WORLD_W, WORLD_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); + worldOnly(this.ghost); + screenOnly(this.hud); + screenOnly(this.menu); + 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'); @@ -149,14 +180,14 @@ export default class JigsawGame extends Phaser.Scene { // ── Background / table ───────────────────────────────────────────────────── buildBackground() { const bg = this.add.graphics().setDepth(0); - // felt-like table - bg.fillStyle(COLORS.bg, 1).fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); + // felt-like table covering the whole (bigger) field + bg.fillStyle(COLORS.bg, 1).fillRect(0, 0, WORLD_W, WORLD_H); // subtle framed panel to read as a table - bg.fillStyle(0x000000, 0.22).fillRoundedRect(28, 28, GAME_WIDTH - 56, GAME_HEIGHT - 56, 26); - bg.lineStyle(2, COLORS.accent, 0.35).strokeRoundedRect(34, 34, GAME_WIDTH - 68, GAME_HEIGHT - 68, 22); - bg.fillStyle(COLORS.panel, 0.22).fillRoundedRect(40, 40, GAME_WIDTH - 80, GAME_HEIGHT - 80, 18); + bg.fillStyle(0x000000, 0.22).fillRoundedRect(28, 28, WORLD_W - 56, WORLD_H - 56, 26); + bg.lineStyle(2, COLORS.accent, 0.35).strokeRoundedRect(34, 34, WORLD_W - 68, WORLD_H - 68, 22); + bg.fillStyle(COLORS.panel, 0.22).fillRoundedRect(40, 40, WORLD_W - 80, WORLD_H - 80, 18); this.bg = bg; - bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT), Phaser.Geom.Rectangle.Contains); + bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, WORLD_W, WORLD_H), Phaser.Geom.Rectangle.Contains); bg.on('pointerdown', (pointer) => this.onTableDown(pointer)); } @@ -314,6 +345,7 @@ export default class JigsawGame extends Phaser.Scene { 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); } @@ -323,7 +355,7 @@ export default class JigsawGame extends Phaser.Scene { if (s === 'menu') { this.menu.setVisible(true); this.setBoardVisible(false); - this.setZoomTo(1); + this.resetToCanvas(); } else { this.menu.setVisible(false); } @@ -384,6 +416,7 @@ export default class JigsawGame extends Phaser.Scene { 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 @@ -410,6 +443,7 @@ export default class JigsawGame extends Phaser.Scene { // Ghost target shown while dragging (kept above pieces so it stays visible) this.ghost = this.add.image(0, 0).setOrigin(0.5).setAlpha(0.22).setVisible(false).setDepth(9000); + if (this.hudCam) this.ghost.cameraFilter = this.hudCam.id; // world camera only this.updateStats(); this.setState('playing'); @@ -459,10 +493,11 @@ export default class JigsawGame extends Phaser.Scene { scatterSpots(n) { const g = mulberry32(this.seed ^ 0x9e3779b9); - // Tray = the whole table 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). - const tray = { x: 40, y: HUD_H + 16, w: GAME_WIDTH - 80, h: GAME_HEIGHT - (HUD_H + 16) - 20 }; + // 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: 40, w: WORLD_W - 80, h: WORLD_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 @@ -668,14 +703,17 @@ export default class JigsawGame extends Phaser.Scene { 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, GAME_WIDTH - vw)); - cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, Math.max(0, GAME_HEIGHT - vh)); + cam.scrollX = Phaser.Math.Clamp(cam.scrollX, 0, Math.max(0, WORLD_W - vw)); + cam.scrollY = Phaser.Math.Clamp(cam.scrollY, 0, Math.max(0, 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, 1, MAX_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; @@ -691,17 +729,34 @@ export default class JigsawGame extends Phaser.Scene { resetView() { this.setZoomTo(1); } - setZoomTo(z) { + // Menu/loading views live in canvas space (top-left of the world). + resetToCanvas() { const cam = this.cameras.main; - cam.setZoom(Phaser.Math.Clamp(z, 1, MAX_ZOOM)); + cam.setZoom(1); cam.setScroll(0, 0); } - pinHUD() { - if (!this.hud) return; + setZoomTo(z) { const cam = this.cameras.main; - this.hud.setPosition(cam.scrollX, cam.scrollY); - this.hud.setScale(1 / cam.zoom); + 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 ───────────────────────────────────────────────────────────── @@ -736,6 +791,7 @@ export default class JigsawGame extends Phaser.Scene { 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.rectangle(cx, cy, 620, 420, 0x17130c, 0.98); const frame = this.add.graphics(); @@ -791,7 +847,6 @@ export default class JigsawGame extends Phaser.Scene { // ── Loop ─────────────────────────────────────────────────────────────────── update(time) { - this.pinHUD(); if (this.state === 'playing') { if (!this.startTime) this.startTime = time; this.elapsed = Math.max(0, (time - this.startTime) / 1000);