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:
parent
a8b4d15e8c
commit
22e6b68cef
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue