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
This commit is contained in:
Brian Fertig 2026-08-30 18:24:36 -06:00
parent a8b4d15e8c
commit 22e6b68cef
1 changed files with 78 additions and 23 deletions

View File

@ -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,18 +729,35 @@ 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;
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() {
@ -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);