Add Jigsaw puzzle game with drag-and-drop pieces and pan/zoom
- New Phaser scene (JigsawGame) supporting 4 difficulty tiers (25–144 pieces), image selection, timer, hint overlay, and win screen. - Pure geometry module (JigsawLogic) generates seeded tab/blank outlines so adjacent pieces mesh exactly; also provides deterministic scatter positions. - Pieces are drawn to canvas textures with depth shading; drag-to-snap with a ghost target, camera pan on empty table, and wheel/button zoom (1x–4x). - Registered in gamesRegistry, main.js scene list, and GameRoomScene slug map; game-icons.png updated for the new icon frame.
This commit is contained in:
parent
cb4ac7bd85
commit
a8b4d15e8c
Binary file not shown.
|
Before Width: | Height: | Size: 339 KiB After Width: | Height: | Size: 341 KiB |
|
|
@ -123,3 +123,4 @@ registerGame({ slug: 'mastervega', name: 'Master of Vega', category: 'arcade-con
|
|||
registerGame({ slug: 'wolfenstein', name: 'Wolfenstein 3D', category: 'arcade-console-pc', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 93 });
|
||||
registerGame({ slug: 'pipepuzzle', name: 'Pipe Puzzle', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 94 });
|
||||
registerGame({ slug: 'tents', name: 'Tents & Trees', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 95 });
|
||||
registerGame({ slug: 'jigsaw', name: 'Jigsaw', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 96 });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,801 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { COLORS, GAME_WIDTH, GAME_HEIGHT } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { SFX, playSound } from '../../ui/Sounds.js';
|
||||
import {
|
||||
DIFFICULTIES, DIFFICULTY_ORDER,
|
||||
makeJigsaw, cellOutline, tracePath, mulberry32,
|
||||
} from './JigsawLogic.js';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Layout (world == the 1920×1080 canvas). Pieces live on a big "table"; the
|
||||
// board target is a fixed square. Drag pieces to their slots; drag empty space
|
||||
// to pan; scroll / +/- to zoom.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
const HUD_H = 88;
|
||||
const BOARD_SIZE = 700;
|
||||
const BOARD = { x: (GAME_WIDTH - BOARD_SIZE) / 2, y: 150, size: BOARD_SIZE };
|
||||
const MAX_ZOOM = 4;
|
||||
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
|
||||
|
||||
function offsetOutline(o, ox, oy) {
|
||||
return {
|
||||
start: { x: o.start.x + ox, y: o.start.y + oy },
|
||||
cmds: o.cmds.map((c) => c.t === 'line'
|
||||
? { t: 'line', x: c.x + ox, y: c.y + oy }
|
||||
: { t: 'bezier', c1: { x: c.c1.x + ox, y: c.c1.y + oy }, c2: { x: c.c2.x + ox, y: c.c2.y + oy }, x: c.x + ox, y: c.y + oy }),
|
||||
};
|
||||
}
|
||||
|
||||
// Load a picture as a plain HTMLImageElement (cached by URL). We draw pieces
|
||||
// and thumbnails straight from this element rather than relying on Phaser's
|
||||
// texture-source API, which differs across Phaser 3 versions.
|
||||
const _imgCache = {};
|
||||
function loadImage(url) {
|
||||
if (_imgCache[url]) return Promise.resolve(_imgCache[url]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => { _imgCache[url] = img; resolve(img); };
|
||||
img.onerror = () => reject(new Error('image load failed: ' + url));
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export default class JigsawGame extends Phaser.Scene {
|
||||
constructor() {
|
||||
super('jigsaw-game');
|
||||
this._pt = new Phaser.Math.Vector2();
|
||||
}
|
||||
|
||||
create() {
|
||||
this.artwork = [];
|
||||
this.imageIndex = 0;
|
||||
this.buttons = [];
|
||||
this.pieces = [];
|
||||
this.state = 'menu';
|
||||
this.dragging = null;
|
||||
this.panning = null;
|
||||
this.placed = 0;
|
||||
this.zTop = 1000;
|
||||
this.startTime = 0;
|
||||
this.elapsed = 0;
|
||||
this.hintOn = true;
|
||||
this.selectedDiff = 'easy';
|
||||
|
||||
this.loadArtwork();
|
||||
this.buildBackground();
|
||||
this.buildHUD();
|
||||
this.buildMenu();
|
||||
this.bindInput();
|
||||
|
||||
this.cameras.main.setBounds(0, 0, GAME_WIDTH, GAME_HEIGHT);
|
||||
this.cameras.main.setZoom(1);
|
||||
this.cameras.main.setScroll(0, 0);
|
||||
this.state = 'menu';
|
||||
}
|
||||
|
||||
// ── Artwork (Shift images, lazy-loaded) ────────────────────────────────────
|
||||
loadArtwork() {
|
||||
const data = this.cache.json.get('shift-artwork');
|
||||
const list = (data && data.artwork) || (Array.isArray(data) ? data : []);
|
||||
this.artwork = list.filter((a) => a && a.path);
|
||||
if (!this.artwork.length) this.artwork = [{ name: 'Puzzle', path: 'assets/images/shift/alien-world.png' }];
|
||||
}
|
||||
|
||||
currentImage() { return this.artwork[this.imageIndex % this.artwork.length]; }
|
||||
|
||||
previewImage(dir) {
|
||||
this.imageIndex = (this.imageIndex + dir + this.artwork.length) % this.artwork.length;
|
||||
const item = this.currentImage();
|
||||
playSound(this, SFX.UI_FLIP);
|
||||
this.loadPreview(item);
|
||||
}
|
||||
|
||||
randomImage() {
|
||||
let i = this.imageIndex;
|
||||
while (i === this.imageIndex && this.artwork.length > 1) i = Math.floor(Math.random() * this.artwork.length);
|
||||
this.imageIndex = i;
|
||||
playSound(this, SFX.UI_ACTIVATE);
|
||||
this.loadPreview(this.currentImage());
|
||||
}
|
||||
|
||||
loadPreview(item) {
|
||||
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
|
||||
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
|
||||
const key = item.path;
|
||||
this._loadToken = (this._loadToken || 0) + 1;
|
||||
const token = this._loadToken;
|
||||
loadImage(key).then((srcImg) => {
|
||||
if (token !== this._loadToken) return; // superseded by a newer pick
|
||||
if (!this.menu || !this.menu.visible) return;
|
||||
const box = 300, cx = this.thumbBoxX, cy = this.thumbBoxY;
|
||||
const S = 220;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.save();
|
||||
const rr = 16;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(rr, 0); ctx.arcTo(S, 0, S, S, rr); ctx.arcTo(S, S, 0, S, rr);
|
||||
ctx.arcTo(0, S, 0, 0, rr); ctx.arcTo(0, 0, S, 0, rr); ctx.closePath();
|
||||
ctx.clip();
|
||||
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
|
||||
const sc = Math.max(S / iw, S / ih);
|
||||
ctx.drawImage(srcImg, (S - iw * sc) / 2, (S - ih * sc) / 2, iw * sc, ih * sc);
|
||||
ctx.restore();
|
||||
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
|
||||
this.textures.addCanvas('jigsaw-thumb', cv);
|
||||
const img = this.add.image(cx, cy, 'jigsaw-thumb').setDisplaySize(box, box);
|
||||
this.menu.add(img);
|
||||
img.setPosition(cx, cy);
|
||||
this.previewImg = img;
|
||||
this.addThumbBorder();
|
||||
if (this.thumbName) this.thumbName.setText(item.name || 'Image');
|
||||
}).catch((e) => { console.warn('jigsaw preview:', e.message); });
|
||||
}
|
||||
|
||||
addThumbBorder() {
|
||||
if (this.thumbBorder) this.thumbBorder.destroy();
|
||||
const g = this.add.graphics();
|
||||
g.lineStyle(3, COLORS.accent, 0.9);
|
||||
g.strokeRoundedRect(-150, -150, 300, 300, 16);
|
||||
this.thumbBorder = g;
|
||||
const parent = this.menu || this;
|
||||
if (parent.add) parent.add(g);
|
||||
g.setPosition(this.thumbBoxX, this.thumbBoxY);
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
// 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);
|
||||
this.bg = bg;
|
||||
bg.setInteractive(new Phaser.Geom.Rectangle(0, 0, GAME_WIDTH, GAME_HEIGHT), Phaser.Geom.Rectangle.Contains);
|
||||
bg.on('pointerdown', (pointer) => this.onTableDown(pointer));
|
||||
}
|
||||
|
||||
// ── HUD (pinned to the top of the screen regardless of pan/zoom) ──────────
|
||||
buildHUD() {
|
||||
this.hud = this.add.container(0, 0).setDepth(9000);
|
||||
const bar = this.add.rectangle(GAME_WIDTH / 2, HUD_H / 2, GAME_WIDTH, HUD_H, 0x000000, 0.32);
|
||||
const line = this.add.rectangle(GAME_WIDTH / 2, HUD_H, GAME_WIDTH, 3, COLORS.accent, 0.7);
|
||||
this.hud.add([bar, line]);
|
||||
|
||||
this.title = this.add.text(72, HUD_H / 2, 'JIGSAW', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '30px', color: COLORS.textHex,
|
||||
letterSpacing: 4,
|
||||
}).setOrigin(0.5, 0.5);
|
||||
this.hud.add(this.title);
|
||||
|
||||
this.diffBadge = this.add.text(195, HUD_H / 2, 'Easy · 25', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '19px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5, 0.5);
|
||||
this.hud.add(this.diffBadge);
|
||||
|
||||
this.stats = this.add.text(GAME_WIDTH / 2, HUD_H / 2, '', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
|
||||
}).setOrigin(0.5, 0.5);
|
||||
this.hud.add(this.stats);
|
||||
|
||||
const B = (label, x, w, onClick, o = {}) => this.mkButton(this.hud, label, x, HUD_H / 2, w, 54, onClick, o);
|
||||
this.btnMenu = B('Menu', 1845, 118, () => this.toMenu());
|
||||
this.btnHome = B('⌂', 1735, 54, () => this.resetView(), { fontSize: 24 });
|
||||
this.btnZoomIn = B('+', 1665, 54, () => this.zoomStep(1.2), { fontSize: 26 });
|
||||
this.btnZoomOut= B('-', 1595, 54, () => this.zoomStep(1 / 1.2), { fontSize: 26 });
|
||||
this.btnHint = B('Hint', 1490, 84, () => this.toggleHint(), { fontSize: 22 });
|
||||
this.btnNew = B('New', 1365, 96, () => this.restart(), { fontSize: 22 });
|
||||
this.btnHint.setActive(true);
|
||||
|
||||
this.hudVisible = () => this.hud.visible;
|
||||
this.setHUDForState('menu');
|
||||
}
|
||||
|
||||
setHUDForState(state) {
|
||||
const playing = state === 'playing' || state === 'won';
|
||||
[this.btnMenu, this.btnHome, this.btnZoomIn, this.btnZoomOut, this.btnHint, this.btnNew].forEach((b) => {
|
||||
const on = playing && (b !== this.btnNew || state === 'playing');
|
||||
b.visible = on;
|
||||
if (b === this.btnNew && state === 'won') b.visible = true;
|
||||
});
|
||||
this.diffBadge.visible = playing;
|
||||
this.stats.visible = playing;
|
||||
this.title.setVisible(true);
|
||||
}
|
||||
|
||||
// ── Button factory (registered for hit-testing + cleanup) ──────────────────
|
||||
mkButton(parent, label, x, y, w, h, onClick, o = {}) {
|
||||
// Swallow a click that is really the tail-end of a piece drag (Phaser fires
|
||||
// pointerup on whatever is under the cursor at release, even mid-drag).
|
||||
const safe = () => {
|
||||
if (this.suppressNextButtonClick) { this.suppressNextButtonClick = false; return; }
|
||||
if (onClick) onClick();
|
||||
};
|
||||
const b = new Button(this, x, y, label, safe, {
|
||||
width: w, height: h,
|
||||
fontSize: o.fontSize || 24,
|
||||
variant: o.variant || 'solid',
|
||||
bg: o.bg || COLORS.panel,
|
||||
});
|
||||
parent.add(b);
|
||||
this.buttons.push(b);
|
||||
return b;
|
||||
}
|
||||
|
||||
buttonHit(cx, cy) {
|
||||
for (const b of this.buttons) {
|
||||
if (!b.visible || !b.parent || !b.parent.visible) continue;
|
||||
const w = b.options.width, h = b.options.height;
|
||||
if (Math.abs(cx - b.x) <= w / 2 && Math.abs(cy - b.y) <= h / 2) return b;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Menu ───────────────────────────────────────────────────────────────────
|
||||
buildMenu() {
|
||||
this.menu = this.add.container(0, 0).setDepth(8000);
|
||||
|
||||
const W = 1160, H = 820, cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2 + 12;
|
||||
const panel = this.add.rectangle(cx, cy, W, H, 0x17130c, 0.96);
|
||||
const frame = this.add.graphics();
|
||||
frame.lineStyle(3, COLORS.accent, 0.85).strokeRoundedRect(cx - W / 2, cy - H / 2, W, H, 20);
|
||||
this.menu.add([panel, frame]);
|
||||
|
||||
const addT = (x, y, s, color, o = {}) => {
|
||||
const t = this.add.text(x, y, s, { fontFamily: '"Julius Sans One"', fontSize: o.size, color: color, ...o }).setOrigin(0.5);
|
||||
this.menu.add(t); return t;
|
||||
};
|
||||
addT(cx, cy - 350, 'JIGSAW', COLORS.textHex, { size: '58px', letterSpacing: 10 });
|
||||
addT(cx, cy - 308, 'Assemble the picture. Drag pieces, pan the table, zoom to fit.', COLORS.mutedHex, { size: '20px' });
|
||||
|
||||
// Difficulty
|
||||
addT(cx, cy - 250, 'Difficulty', COLORS.goldHex, { size: '24px', letterSpacing: 3 });
|
||||
const diffLabels = DIFFICULTY_ORDER.map((k) => DIFFICULTIES[k]);
|
||||
const diffY = cy - 205;
|
||||
this.diffButtons = [];
|
||||
const bw = 210, gap = 22, totalW = diffLabels.length * bw + (diffLabels.length - 1) * gap;
|
||||
let bx = cx - totalW / 2 + bw / 2;
|
||||
DIFFICULTY_ORDER.forEach((k, i) => {
|
||||
const cfg = DIFFICULTIES[k];
|
||||
const b = this.mkButton(this.menu, `${cfg.label} · ${cfg.cols * cfg.rows}`, bx, diffY, bw, 64, () => this.selectDifficulty(k), { fontSize: 22 });
|
||||
this.diffButtons.push({ key: k, btn: b });
|
||||
bx += bw + gap;
|
||||
});
|
||||
|
||||
// Image row
|
||||
addT(cx, cy - 110, 'Image', COLORS.goldHex, { size: '24px', letterSpacing: 3 });
|
||||
this.thumbBoxX = cx; this.thumbBoxY = cy + 20;
|
||||
this.thumbName = addT(cx, cy + 175, '', COLORS.textHex, { size: '20px' });
|
||||
const prev = this.mkButton(this.menu, '‹', cx - 205, cy + 20, 64, 64, () => this.previewImage(-1), { fontSize: 34 });
|
||||
const next = this.mkButton(this.menu, '›', cx + 205, cy + 20, 64, 64, () => this.previewImage(1), { fontSize: 34 });
|
||||
const rnd = this.mkButton(this.menu, '🎲 Random', cx, cy + 215, 190, 52, () => this.randomImage(), { fontSize: 22 });
|
||||
this.thumbName.y = cy + 160;
|
||||
|
||||
// Start
|
||||
const start = this.mkButton(this.menu, 'Start Puzzle ▸', cx, cy + 315, 320, 74, () => this.startPuzzle(), { fontSize: 30, bg: COLORS.gold });
|
||||
this.startButton = start;
|
||||
|
||||
// Load the first preview
|
||||
this.selectDifficulty('easy');
|
||||
this.loadPreview(this.currentImage());
|
||||
}
|
||||
|
||||
selectDifficulty(key) {
|
||||
this.selectedDiff = key;
|
||||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||||
this.diffButtons.forEach((d) => d.btn.setActive(d.key === key));
|
||||
}
|
||||
|
||||
// ── Puzzle start ───────────────────────────────────────────────────────────
|
||||
startPuzzle() {
|
||||
const item = this.currentImage();
|
||||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||||
this.menu.setVisible(false);
|
||||
this.setState('loading');
|
||||
this.showLoading(true);
|
||||
loadImage(item.path).then(() => {
|
||||
this.showLoading(false);
|
||||
this.beginPlay(item);
|
||||
}).catch((e) => {
|
||||
this.showLoading(false);
|
||||
console.error('jigsaw image load failed:', e.message);
|
||||
this.setState('menu');
|
||||
this.menu.setVisible(true);
|
||||
});
|
||||
}
|
||||
|
||||
showLoading(on) {
|
||||
if (on && !this.loadingLabel) {
|
||||
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);
|
||||
} else if (this.loadingLabel) this.loadingLabel.setVisible(on);
|
||||
}
|
||||
|
||||
setState(s) {
|
||||
this.state = s;
|
||||
this.setHUDForState(s);
|
||||
if (s === 'menu') {
|
||||
this.menu.setVisible(true);
|
||||
this.setBoardVisible(false);
|
||||
this.setZoomTo(1);
|
||||
} else {
|
||||
this.menu.setVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Build board + pieces ───────────────────────────────────────────────────
|
||||
beginPlay(item) {
|
||||
this.teardownPlay();
|
||||
const cfg = DIFFICULTIES[this.selectedDiff];
|
||||
this.cols = cfg.cols; this.rows = cfg.rows;
|
||||
this.total = cfg.cols * cfg.rows;
|
||||
this.cell = BOARD_SIZE / cfg.cols;
|
||||
this.placed = 0;
|
||||
this.startTime = 0; this.elapsed = 0;
|
||||
this.seed = (Math.random() * 1e9) | 0;
|
||||
this.jig = makeJigsaw(this.cols, this.rows, this.seed);
|
||||
this.diffLabel = `${cfg.label} · ${this.total} pieces`;
|
||||
this.imageName = item.name || 'Image';
|
||||
|
||||
// Board panel + reference picture
|
||||
const bcx = BOARD.x + BOARD.size / 2, bcy = BOARD.y + BOARD.size / 2;
|
||||
const panel = this.add.rectangle(bcx, bcy, BOARD.size + 28, BOARD.size + 28, 0x000000, 0.28).setDepth(1);
|
||||
const boardFrame = this.add.graphics().setDepth(2);
|
||||
boardFrame.lineStyle(3, COLORS.accent, 0.8).strokeRoundedRect(BOARD.x - 14, BOARD.y - 14, BOARD.size + 28, BOARD.size + 28, 12);
|
||||
boardFrame.lineStyle(2, 0xffffff, 0.15);
|
||||
boardFrame.strokeRect(BOARD.x, BOARD.y, BOARD.size, BOARD.size);
|
||||
this.boardPanel = panel;
|
||||
this.boardFrame = boardFrame;
|
||||
|
||||
const srcImg = _imgCache[item.path];
|
||||
// Build the faint reference into a canvas texture (cover-cropped square),
|
||||
// then place it. Texture must exist before the image references it.
|
||||
const R = 640;
|
||||
const rcv = document.createElement('canvas');
|
||||
rcv.width = rcv.height = R;
|
||||
const rctx = rcv.getContext('2d');
|
||||
if (srcImg) {
|
||||
const iw = srcImg.naturalWidth || srcImg.width, ih = srcImg.naturalHeight || srcImg.height;
|
||||
const sc = Math.max(R / iw, R / ih);
|
||||
rctx.drawImage(srcImg, (R - iw * sc) / 2, (R - ih * sc) / 2, iw * sc, ih * sc);
|
||||
} else {
|
||||
rctx.fillStyle = '#5a7b8a'; rctx.fillRect(0, 0, R, R);
|
||||
}
|
||||
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
|
||||
this.textures.addCanvas('jigsaw-ref', rcv);
|
||||
this.refImage = this.add.image(bcx, bcy, 'jigsaw-ref').setDisplaySize(BOARD.size, BOARD.size).setAlpha(0.16).setDepth(3);
|
||||
|
||||
// Build piece textures + sprites
|
||||
const texKey = (r, c) => `jg-${this.seed}-${r}-${c}`;
|
||||
const boardW = this.cols * this.cell, boardH = this.rows * this.cell;
|
||||
|
||||
this.pieces = [];
|
||||
for (let r = 0; r < this.rows; r++) {
|
||||
for (let c = 0; c < this.cols; c++) {
|
||||
const key = texKey(r, c);
|
||||
const cv = this.buildPieceCanvas(r, c, srcImg, boardW, boardH);
|
||||
if (this.textures.exists(key)) this.textures.remove(key);
|
||||
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);
|
||||
// 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
|
||||
// piece's hit area. (With the old ±cell box, ~35 neighbour pairs overlapped
|
||||
// and the click resolved to whichever had the highest index — the bottom-
|
||||
// right piece — far away from the cursor.)
|
||||
// Pieces stay interactive only for the hover cursor. The actual grab is
|
||||
// resolved centrally in onTableDown (nearest piece to the pointer) so a
|
||||
// click can never resolve to a distant neighbour's overlapping hit box.
|
||||
const grab = this.cell * GRAB_FRAC;
|
||||
img.setInteractive({ useHandCursor: true, hitArea: new Phaser.Geom.Rectangle(-grab, -grab, grab * 2, grab * 2), hitAreaCallback: Phaser.Geom.Rectangle.Contains });
|
||||
this.pieces.push({ r, c, img, home, placed: false, key, depth: 10 });
|
||||
}
|
||||
}
|
||||
|
||||
// Scatter pieces around (but off) the board. scatterSpots always returns
|
||||
// exactly `total` spots; the `|| { x: BOARD.x, y: BOARD.y }` is just a
|
||||
// last-resort guard so a layout change can never crash the game.
|
||||
const spots = this.scatterSpots(this.total);
|
||||
this.pieces.forEach((p, i) => {
|
||||
const s = spots[i] || { x: BOARD.x + BOARD.size / 2, y: BOARD.y + BOARD.size / 2 };
|
||||
p.img.setPosition(s.x, s.y);
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
this.updateStats();
|
||||
this.setState('playing');
|
||||
this.resetView();
|
||||
}
|
||||
|
||||
buildPieceCanvas(r, c, src, boardW, boardH) {
|
||||
const cell = this.cell;
|
||||
const S = Math.ceil(cell * 2) + 2;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = cv.height = S;
|
||||
const ctx = cv.getContext('2d');
|
||||
const ox = S / 2 - (c + 0.5) * cell;
|
||||
const oy = S / 2 - (r + 0.5) * cell;
|
||||
const outline = offsetOutline(cellOutline(this.jig, r, c, cell, cell), ox, oy);
|
||||
|
||||
ctx.save();
|
||||
tracePath(ctx, outline);
|
||||
ctx.clip();
|
||||
if (src) ctx.drawImage(src, ox, oy, boardW, boardH);
|
||||
else {
|
||||
// Fallback swatch so a piece is still visible if the image is missing.
|
||||
const hue = ((r * this.cols + c) * 47) % 360;
|
||||
ctx.fillStyle = `hsl(${hue} 45% 60%)`;
|
||||
ctx.fillRect(0, 0, S, S);
|
||||
}
|
||||
// depth shading
|
||||
const g = ctx.createLinearGradient(0, 0, 0, S);
|
||||
g.addColorStop(0, 'rgba(255,255,255,0.10)');
|
||||
g.addColorStop(0.5, 'rgba(255,255,255,0)');
|
||||
g.addColorStop(1, 'rgba(0,0,0,0.20)');
|
||||
ctx.fillStyle = g;
|
||||
ctx.fillRect(0, 0, S, S);
|
||||
ctx.restore();
|
||||
|
||||
tracePath(ctx, outline);
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.lineWidth = Math.max(1.5, cell * 0.028);
|
||||
ctx.strokeStyle = 'rgba(12,9,6,0.6)';
|
||||
ctx.stroke();
|
||||
tracePath(ctx, outline);
|
||||
ctx.lineWidth = Math.max(1, cell * 0.014);
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.16)';
|
||||
ctx.stroke();
|
||||
return cv;
|
||||
}
|
||||
|
||||
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 };
|
||||
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
|
||||
// other's grab region (the old rejection-scatter over-packed the table and
|
||||
// left some pieces ungrabbable until their neighbours were placed).
|
||||
//
|
||||
// KEY: candidate CENTRES are computed without jitter, so their COUNT is
|
||||
// deterministic for a given spacing (the old code jittered first and then
|
||||
// dropped points that wandered off-table / under the board, so the count was
|
||||
// seed-dependent and sometimes came up short — crashing beginPlay with
|
||||
// `undefined.x`). We start roomy and tighten the grid until it holds >= n
|
||||
// centres. The floor is small enough that a fit always exists, so we never
|
||||
// come up short and never emit an off-canvas spot.
|
||||
const floor = this.cell; // densest we'll allow — one base-cell apart
|
||||
let spacing = this.cell * 1.7;
|
||||
let cells = [];
|
||||
for (let i = 0; i < 40; i++) {
|
||||
cells = this.gridCells(tray, boardBox, spacing, grabR);
|
||||
if (cells.length >= n) break;
|
||||
spacing = Math.max(floor, spacing * 0.95);
|
||||
}
|
||||
|
||||
// Cosmetic jitter for a natural look. Only jitter cells that sit safely away
|
||||
// from the board and the tray edges so the jitter can never push a piece
|
||||
// under the board or off the table. When the grid is tight (spacing <= 2x
|
||||
// grab) we skip jitter so the centre-gap never shrinks below the fit.
|
||||
const J = Math.max(0, spacing - 2 * grabR) * 0.2;
|
||||
const safe = (p) => {
|
||||
const bx0 = boardBox.x0 - J, by0 = boardBox.y0 - J, bx1 = boardBox.x1 + J, by1 = boardBox.y1 + J;
|
||||
const tx0 = tray.x + grabR + J, tx1 = tray.x + tray.w - grabR - J;
|
||||
const ty0 = tray.y + grabR + J, ty1 = tray.y + tray.h - grabR - J;
|
||||
return !(p.x > bx0 && p.x < bx1 && p.y > by0 && p.y < by1) &&
|
||||
(p.x > tx0 && p.x < tx1 && p.y > ty0 && p.y < ty1);
|
||||
};
|
||||
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
|
||||
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
|
||||
const pts = cells.map((p) => safe(p) && J > 0
|
||||
? { x: clamp(p.x + (g() - 0.5) * 2 * J, x0, x1), y: clamp(p.y + (g() - 0.5) * 2 * J, y0, y1) }
|
||||
: { x: clamp(p.x, x0, x1), y: clamp(p.y, y0, y1) });
|
||||
|
||||
// Defensive backfill (should never be needed with the floor above): add spots
|
||||
// in a guaranteed-clear left strip, staying strictly inside the tray so we
|
||||
// can never emit an off-canvas position.
|
||||
if (pts.length < n) {
|
||||
let k = pts.length;
|
||||
while (pts.length < n) {
|
||||
const col = k % 3, row = Math.floor(k / 3);
|
||||
pts.push({
|
||||
x: clamp(tray.x + grabR + col * 2 * grabR, x0, x1),
|
||||
y: clamp(tray.y + grabR + row * 2 * grabR, y0, y1),
|
||||
});
|
||||
k++;
|
||||
}
|
||||
}
|
||||
|
||||
// shuffle so pieces aren't in grid order, then take n
|
||||
for (let i = pts.length - 1; i > 0; i--) { const j = (g() * (i + 1)) | 0; const t = pts[i]; pts[i] = pts[j]; pts[j] = t; }
|
||||
return pts.slice(0, n);
|
||||
}
|
||||
|
||||
// Un-jittered grid CENTRES inside the tray, clear of the board. Count is
|
||||
// deterministic in `spacing` (no RNG), so the caller can rely on it to
|
||||
// guarantee a fit. Centres are kept a full grab-radius away from the board.
|
||||
gridCells(tray, boardBox, spacing, grabR) {
|
||||
const out = [];
|
||||
const x0 = tray.x + grabR, x1 = tray.x + tray.w - grabR;
|
||||
const y0 = tray.y + grabR, y1 = tray.y + tray.h - grabR;
|
||||
const cs = Math.round(x0 / spacing), ce = Math.floor(x1 / spacing);
|
||||
const rs = Math.round(y0 / spacing), re = Math.floor(y1 / spacing);
|
||||
for (let r = rs; r <= re; r++) {
|
||||
for (let c = cs; c <= ce; c++) {
|
||||
const x = c * spacing, y = r * spacing;
|
||||
if (x < x0 || x > x1 || y < y0 || y > y1) continue;
|
||||
if (x > boardBox.x0 && x < boardBox.x1 && y > boardBox.y0 && y < boardBox.y1) continue;
|
||||
out.push({ x, y });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Interaction ────────────────────────────────────────────────────────────
|
||||
bindInput() {
|
||||
this.input.on('pointermove', (pointer) => this.onPointerMove(pointer));
|
||||
this.input.on('pointerup', () => this.onPointerUp());
|
||||
this.input.on('pointerupoutside', () => this.onPointerUp());
|
||||
this.input.on('wheel', (pointer, _o, _dx, dy) => {
|
||||
if (this.state !== 'playing') return;
|
||||
const c = this.canvasPos(pointer);
|
||||
this.zoomAt(c.x, c.y, dy < 0 ? 1.15 : 1 / 1.15);
|
||||
});
|
||||
}
|
||||
|
||||
canvasPos(pointer) {
|
||||
// pointer.x/y are already in screen (game-canvas) space per the Phaser 3.90
|
||||
// Pointer API ("The value is in screen space"). Phaser 3.90 has no
|
||||
// ScaleManager.getPointerPosition, so we read the pointer directly.
|
||||
return { x: pointer.x, y: pointer.y };
|
||||
}
|
||||
|
||||
worldOf(pointer) {
|
||||
// Convert screen (canvas) space -> world space through the active camera
|
||||
// matrix (accounts for scroll + zoom). Deterministic: reads the current
|
||||
// pointer position and the current camera matrix.
|
||||
const c = this.canvasPos(pointer);
|
||||
const w = this.cameras.main.getWorldPoint(c.x, c.y);
|
||||
return { x: w.x, y: w.y };
|
||||
}
|
||||
|
||||
nearestPiece(w) {
|
||||
if (this.state !== 'playing') return null;
|
||||
const thr = this.cell * GRAB_FRAC; // grab radius: covers the piece body incl. its knobs
|
||||
let best = null, bestD = Infinity;
|
||||
for (const p of this.pieces) {
|
||||
if (p.placed) continue;
|
||||
const d = Math.hypot(p.img.x - w.x, p.img.y - w.y);
|
||||
if (d < thr && d < bestD) { bestD = d; best = p; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
onTableDown(pointer) {
|
||||
if (this.state !== 'playing') return;
|
||||
const c = this.canvasPos(pointer);
|
||||
if (this.buttonHit(c.x, c.y)) return; // let HUD buttons work
|
||||
const piece = this.nearestPiece(this.worldOf(pointer));
|
||||
if (piece) { this.grabPiece(piece, pointer); return; }
|
||||
const cam = this.cameras.main; // empty table → pan
|
||||
this.panning = { cx: c.x, cy: c.y, camX: cam.scrollX, camY: cam.scrollY };
|
||||
}
|
||||
|
||||
grabPiece(piece, pointer) {
|
||||
const w = this.worldOf(pointer);
|
||||
this.zTop += 1;
|
||||
piece.depth = this.zTop;
|
||||
piece.img.setDepth(this.zTop);
|
||||
this.dragging = { piece, offX: piece.img.x - w.x, offY: piece.img.y - w.y };
|
||||
this.suppressNextButtonClick = true;
|
||||
this.ghost.setTexture(piece.img.texture.key).setPosition(piece.home.x, piece.home.y).setVisible(true);
|
||||
playSound(this, SFX.UI_PICK);
|
||||
}
|
||||
|
||||
onPointerMove(pointer) {
|
||||
const c = this.canvasPos(pointer);
|
||||
if (this.dragging) {
|
||||
const w = this.worldOf(pointer);
|
||||
const { piece, offX, offY } = this.dragging;
|
||||
piece.img.setPosition(w.x + offX, w.y + offY);
|
||||
return;
|
||||
}
|
||||
if (this.panning) {
|
||||
const cam = this.cameras.main;
|
||||
cam.scrollX = this.panning.camX - (c.x - this.panning.cx) / cam.zoom;
|
||||
cam.scrollY = this.panning.camY - (c.y - this.panning.cy) / cam.zoom;
|
||||
this.clampCamera();
|
||||
}
|
||||
}
|
||||
|
||||
onPointerUp() {
|
||||
if (this.dragging) {
|
||||
const { piece } = this.dragging;
|
||||
this.ghost.setVisible(false);
|
||||
this.dragging = null;
|
||||
this.trySnap(piece);
|
||||
}
|
||||
this.panning = null;
|
||||
this.time.delayedCall(0, () => { this.suppressNextButtonClick = false; });
|
||||
}
|
||||
|
||||
trySnap(piece) {
|
||||
const d = Math.hypot(piece.img.x - piece.home.x, piece.img.y - piece.home.y);
|
||||
if (d < this.cell * SNAP_FRAC) {
|
||||
piece.img.setPosition(piece.home.x, piece.home.y);
|
||||
piece.placed = true;
|
||||
this.placed += 1;
|
||||
piece.img.setDepth(12);
|
||||
this.nudge(piece.img);
|
||||
playSound(this, this.placed === this.total ? SFX.VICTORY_SHORT : SFX.UI_PLACE);
|
||||
this.updateStats();
|
||||
if (this.placed === this.total) this.onWin();
|
||||
} else {
|
||||
// gentle settle
|
||||
this.nudge(piece.img);
|
||||
playSound(this, SFX.PIECE_CLICK);
|
||||
}
|
||||
}
|
||||
|
||||
nudge(obj) {
|
||||
const t = obj.scale;
|
||||
obj.setScale(obj.scale * 1.12);
|
||||
this.tweens.add({ targets: obj, scale: t, duration: 150, ease: 'Quad.easeOut' });
|
||||
}
|
||||
|
||||
updateStats() {
|
||||
if (!this.stats) return;
|
||||
const mm = String(Math.floor(this.elapsed / 60)).padStart(2, '0');
|
||||
const ss = String(Math.floor(this.elapsed % 60)).padStart(2, '0');
|
||||
this.stats.setText(`${this.placed} / ${this.total} · ${mm}:${ss}`);
|
||||
if (this.diffBadge) this.diffBadge.setText(`${this.diffLabel || 'Easy'}`);
|
||||
}
|
||||
|
||||
// ── Camera: pan / zoom ─────────────────────────────────────────────────────
|
||||
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));
|
||||
}
|
||||
|
||||
zoomAt(cx, cy, factor) {
|
||||
const cam = this.cameras.main;
|
||||
const oldZ = cam.zoom;
|
||||
const newZ = Phaser.Math.Clamp(oldZ * factor, 1, MAX_ZOOM);
|
||||
if (Math.abs(newZ - oldZ) < 0.0001) return;
|
||||
const wx = cam.scrollX + cx / oldZ;
|
||||
const wy = cam.scrollY + cy / oldZ;
|
||||
cam.setZoom(newZ);
|
||||
cam.scrollX = wx - cx / newZ;
|
||||
cam.scrollY = wy - cy / newZ;
|
||||
this.clampCamera();
|
||||
}
|
||||
|
||||
zoomStep(factor) {
|
||||
this.zoomAt(GAME_WIDTH / 2, GAME_HEIGHT / 2, factor);
|
||||
}
|
||||
|
||||
resetView() { this.setZoomTo(1); }
|
||||
|
||||
setZoomTo(z) {
|
||||
const cam = this.cameras.main;
|
||||
cam.setZoom(Phaser.Math.Clamp(z, 1, MAX_ZOOM));
|
||||
cam.setScroll(0, 0);
|
||||
}
|
||||
|
||||
pinHUD() {
|
||||
if (!this.hud) return;
|
||||
const cam = this.cameras.main;
|
||||
this.hud.setPosition(cam.scrollX, cam.scrollY);
|
||||
this.hud.setScale(1 / cam.zoom);
|
||||
}
|
||||
|
||||
// ── State flow ─────────────────────────────────────────────────────────────
|
||||
toggleHint() {
|
||||
this.hintOn = !this.hintOn;
|
||||
if (this.refImage) this.refImage.setAlpha(this.hintOn ? 0.16 : 0);
|
||||
this.btnHint.setActive(this.hintOn);
|
||||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||||
}
|
||||
|
||||
restart() {
|
||||
if (this.state === 'menu') return;
|
||||
this.beginPlay(this.currentImage());
|
||||
playSound(this, SFX.EIGHTBIT_ACTIVATE);
|
||||
}
|
||||
|
||||
toMenu() {
|
||||
playSound(this, SFX.EIGHTBIT_SELECT);
|
||||
this.teardownPlay();
|
||||
this.setState('menu');
|
||||
this.loadPreview(this.currentImage());
|
||||
}
|
||||
|
||||
onWin() {
|
||||
this.state = 'won';
|
||||
this.setHUDForState('won');
|
||||
playSound(this, SFX.VICTORY_SHORT);
|
||||
this.showWinOverlay();
|
||||
}
|
||||
|
||||
showWinOverlay() {
|
||||
this.teardownWin();
|
||||
const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2;
|
||||
this.winLayer = this.add.container(0, 0).setDepth(9600);
|
||||
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();
|
||||
frame.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);
|
||||
return t;
|
||||
};
|
||||
this.winLayer.add([dim, panel, frame]);
|
||||
T(cy - 140, 'PUZZLE COMPLETE', COLORS.goldHex, '44px');
|
||||
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, this.diffLabel || '', COLORS.textHex, '26px');
|
||||
T(cy - 28, `Solved in ${mm}:${ss}`, COLORS.textHex, '24px');
|
||||
T(cy + 8, this.imageName || '', COLORS.mutedHex, '20px');
|
||||
const again = this.mkButton(this.winLayer, 'Play Again', cx - 120, cy + 110, 210, 64, () => this.restart(), { fontSize: 26, bg: COLORS.gold });
|
||||
this.mkButton(this.winLayer, 'Menu', cx + 120, cy + 110, 210, 64, () => this.toMenu(), { fontSize: 26 });
|
||||
[panel, frame].forEach((o) => { o.setScale(0.85); this.tweens.add({ targets: o, scale: 1, duration: 280, ease: 'Back.easeOut' }); });
|
||||
this.winObjects = [this.winLayer];
|
||||
}
|
||||
|
||||
teardownWin() {
|
||||
if (this.winObjects) {
|
||||
this.winObjects.forEach((o) => o.destroy());
|
||||
this.winObjects = null;
|
||||
this.winLayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
teardownPlay() {
|
||||
this.teardownWin();
|
||||
this.dragging = null; this.panning = null;
|
||||
if (this.ghost) { this.ghost.destroy(); this.ghost = null; }
|
||||
if (this.boardPanel) this.boardPanel.destroy();
|
||||
if (this.boardFrame) this.boardFrame.destroy();
|
||||
if (this.refImage) this.refImage.destroy();
|
||||
(this.pieces || []).forEach((p) => {
|
||||
p.img.destroy();
|
||||
if (this.textures.exists(p.key)) this.textures.remove(p.key);
|
||||
});
|
||||
this.pieces = [];
|
||||
if (this.previewImg) { this.previewImg.destroy(); this.previewImg = null; }
|
||||
if (this.thumbBorder) { this.thumbBorder.destroy(); this.thumbBorder = null; }
|
||||
if (this.textures.exists('jigsaw-thumb')) this.textures.remove('jigsaw-thumb');
|
||||
if (this.textures.exists('jigsaw-ref')) this.textures.remove('jigsaw-ref');
|
||||
}
|
||||
|
||||
setBoardVisible(v) {
|
||||
[this.boardPanel, this.boardFrame, this.refImage, this.ghost].forEach((o) => o && o.setVisible(v));
|
||||
(this.pieces || []).forEach((p) => p.img.setVisible(v));
|
||||
}
|
||||
|
||||
// ── Loop ───────────────────────────────────────────────────────────────────
|
||||
update(time) {
|
||||
this.pinHUD();
|
||||
if (this.state === 'playing') {
|
||||
if (!this.startTime) this.startTime = time;
|
||||
this.elapsed = Math.max(0, (time - this.startTime) / 1000);
|
||||
this.updateStats();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
// Pure jigsaw-puzzle geometry + grid model. No Phaser/DOM dependencies so the
|
||||
// tab/blank math can be unit-checked in Node and reused verbatim by the scene.
|
||||
//
|
||||
// Model
|
||||
// -----
|
||||
// A cols×rows grid. Every internal edge between two cells carries exactly one
|
||||
// knob (a protruding "tab") that belongs to one of the two cells; the other
|
||||
// cell gets the matching "blank" (indent). Boundary edges are flat.
|
||||
//
|
||||
// H[r][c] ∈ {'L','R'} — the vertical boundary between (r,c) [left] and
|
||||
// (r,c+1) [right]. 'L' → left cell owns the tab.
|
||||
// V[r][c] ∈ {'U','D'} — the horizontal boundary between (r,c) [top] and
|
||||
// (r+1,c) [bottom]. 'U' → top cell owns the tab.
|
||||
//
|
||||
// Because both neighbours compute the SAME knob (same line segment, same side)
|
||||
// and merely traverse it in opposite directions, adjacent pieces mesh exactly.
|
||||
|
||||
export const DIFFICULTIES = {
|
||||
// Piece counts roughly double per tier. Square grids so the (square) source
|
||||
// image fills the board edge-to-edge with no letterboxing.
|
||||
easy: { key: 'easy', label: 'Easy', cols: 5, rows: 5 }, // 25
|
||||
medium: { key: 'medium', label: 'Medium', cols: 6, rows: 6 }, // 36
|
||||
hard: { key: 'hard', label: 'Hard', cols: 9, rows: 9 }, // 81
|
||||
legendary: { key: 'legendary', label: 'Legendary', cols: 12, rows: 12 }, // 144
|
||||
};
|
||||
|
||||
export const DIFFICULTY_ORDER = ['easy', 'medium', 'hard', 'legendary'];
|
||||
|
||||
// Knob shape as fractions of the edge length (see edgeFragment below).
|
||||
// neckFrac: how far in from each end the neck (narrow waist) sits.
|
||||
// ctrlFrac: how far the Bézier controls sit off the edge → peak ≈ 0.75*ctrlFrac.
|
||||
export const DEFAULT_KNOB = { neckFrac: 0.22, ctrlFrac: 0.30 };
|
||||
|
||||
// ── Seeded RNG (mulberry32) so a given seed always yields the same knob layout ──
|
||||
export function mulberry32(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function rng() {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
const rand = Math.random;
|
||||
|
||||
// Build the knob assignment for every internal edge.
|
||||
export function makeJigsaw(cols, rows, seed = null) {
|
||||
const g = seed == null ? rand : mulberry32(seed);
|
||||
const H = [];
|
||||
const V = [];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols - 1; c++) row.push(g() < 0.5 ? 'L' : 'R');
|
||||
H.push(row);
|
||||
}
|
||||
for (let r = 0; r < rows - 1; r++) {
|
||||
const row = [];
|
||||
for (let c = 0; c < cols; c++) row.push(g() < 0.5 ? 'U' : 'D');
|
||||
V.push(row);
|
||||
}
|
||||
return { cols, rows, H, V };
|
||||
}
|
||||
|
||||
// Per-cell edge spec. Each entry: { kind:'flat'|'tab'|'blank', normal:{x,y} }
|
||||
// `normal` is the direction the knob bulges (null for flat edges). This is the
|
||||
// side the shared curve lies on, so it is identical for both adjacent cells.
|
||||
export function cellEdgeSpec(jig, r, c) {
|
||||
const { cols, rows, H, V } = jig;
|
||||
const out = {};
|
||||
|
||||
// Top edge — boundary V[r-1][c] (this cell is the BOTTOM cell of that edge).
|
||||
if (r === 0) out.top = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = V[r - 1][c];
|
||||
const tab = owner === 'D'; // bottom cell owns the tab
|
||||
out.top = tab
|
||||
? { kind: 'tab', normal: { x: 0, y: -1 } }
|
||||
: { kind: 'blank', normal: { x: 0, y: 1 } };
|
||||
}
|
||||
|
||||
// Right edge — boundary H[r][c] (this cell is the LEFT cell of that edge).
|
||||
if (c === cols - 1) out.right = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = H[r][c];
|
||||
const tab = owner === 'L'; // left cell owns the tab
|
||||
out.right = tab
|
||||
? { kind: 'tab', normal: { x: 1, y: 0 } }
|
||||
: { kind: 'blank', normal: { x: -1, y: 0 } };
|
||||
}
|
||||
|
||||
// Bottom edge — boundary V[r][c] (this cell is the TOP cell of that edge).
|
||||
if (r === rows - 1) out.bottom = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = V[r][c];
|
||||
const tab = owner === 'U'; // top cell owns the tab
|
||||
out.bottom = tab
|
||||
? { kind: 'tab', normal: { x: 0, y: 1 } }
|
||||
: { kind: 'blank', normal: { x: 0, y: -1 } };
|
||||
}
|
||||
|
||||
// Left edge — boundary H[r][c-1] (this cell is the RIGHT cell of that edge).
|
||||
if (c === 0) out.left = { kind: 'flat', normal: null };
|
||||
else {
|
||||
const owner = H[r][c - 1];
|
||||
const tab = owner === 'R'; // right cell owns the tab
|
||||
out.left = tab
|
||||
? { kind: 'tab', normal: { x: -1, y: 0 } }
|
||||
: { kind: 'blank', normal: { x: 1, y: 0 } };
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const lerp = (a, b, t) => ({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t });
|
||||
|
||||
// Path commands for one edge, assuming the current point is `p0`.
|
||||
// Flat → a single line. Knob → line to the near neck, one cubic through the
|
||||
// bulb to the far neck, line to `p1`. The curve is direction-independent:
|
||||
// feeding the reversed (p0,p1) yields the same geometric curve (controls swap),
|
||||
// which is what makes neighbouring pieces mesh.
|
||||
export function edgeFragment(p0, p1, edge, knob = DEFAULT_KNOB) {
|
||||
if (!edge || edge.kind === 'flat') {
|
||||
return [{ t: 'line', x: p1.x, y: p1.y }];
|
||||
}
|
||||
const { neckFrac, ctrlFrac } = knob;
|
||||
const nA = lerp(p0, p1, neckFrac);
|
||||
const nB = lerp(p0, p1, 1 - neckFrac);
|
||||
const L = Math.hypot(p1.x - p0.x, p1.y - p0.y);
|
||||
const cA = { x: nA.x + edge.normal.x * ctrlFrac * L, y: nA.y + edge.normal.y * ctrlFrac * L };
|
||||
const cB = { x: nB.x + edge.normal.x * ctrlFrac * L, y: nB.y + edge.normal.y * ctrlFrac * L };
|
||||
return [
|
||||
{ t: 'line', x: nA.x, y: nA.y },
|
||||
{ t: 'bezier', c1: cA, c2: cB, x: nB.x, y: nB.y },
|
||||
{ t: 'line', x: p1.x, y: p1.y },
|
||||
];
|
||||
}
|
||||
|
||||
// Full clockwise outline of cell (r,c). `W`,`H` are the cell size in local
|
||||
// units; `ox`,`oy` the cell's top-left in local units. Returns
|
||||
// { start:{x,y}, cmds:[...] } where cmds are relative to `start`.
|
||||
export function cellOutline(jig, r, c, W, H, ox = 0, oy = 0, knob = DEFAULT_KNOB) {
|
||||
const x0 = ox + c * W;
|
||||
const y0 = oy + r * H;
|
||||
const TL = { x: x0, y: y0 };
|
||||
const TR = { x: x0 + W, y: y0 };
|
||||
const BR = { x: x0 + W, y: y0 + H };
|
||||
const BL = { x: x0, y: y0 + H };
|
||||
const spec = cellEdgeSpec(jig, r, c);
|
||||
|
||||
const cmds = [
|
||||
...edgeFragment(TL, TR, spec.top, knob),
|
||||
...edgeFragment(TR, BR, spec.right, knob),
|
||||
...edgeFragment(BR, BL, spec.bottom, knob),
|
||||
...edgeFragment(BL, TL, spec.left, knob),
|
||||
];
|
||||
return { start: TL, cmds };
|
||||
}
|
||||
|
||||
// Apply path commands to a 2D canvas context (builds the current path).
|
||||
export function tracePath(ctx, outline) {
|
||||
const { start, cmds } = outline;
|
||||
ctx.moveTo(start.x, start.y);
|
||||
for (const c of cmds) {
|
||||
if (c.t === 'line') ctx.lineTo(c.x, c.y);
|
||||
else ctx.bezierCurveTo(c.c1.x, c.c1.y, c.c2.x, c.c2.y, c.x, c.y);
|
||||
}
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// ── Scramble: assign each piece a start position in the "tray" region ─────────
|
||||
// tray = {x, y, w, h} rectangle (in the same local units as the board) where
|
||||
// pieces are scattered. Returns an array aligned to the piece index
|
||||
// (r*cols + c) of {x, y, rotation} (rotation in radians, optional).
|
||||
export function scramblePieces(jig, tray, seed = null, { spread = 0.9, rotation = false } = {}) {
|
||||
const g = seed == null ? rand : mulberry32(seed);
|
||||
const { cols, rows } = jig;
|
||||
const N = cols * rows;
|
||||
const placed = [];
|
||||
const margin = Math.max(tray.w, tray.h) * 0.06;
|
||||
const x0 = tray.x + margin, x1 = tray.x + tray.w - margin;
|
||||
const y0 = tray.y + margin, y1 = tray.y + tray.h - margin;
|
||||
for (let i = 0; i < N; i++) {
|
||||
// Rejection-sample a few tries so pieces don't pile in a single spot.
|
||||
let px = x0 + (x1 - x0) * (0.5 + (g() - 0.5) * spread);
|
||||
let py = y0 + (y1 - y0) * (0.5 + (g() - 0.5) * spread);
|
||||
let tries = 0;
|
||||
while (tries < 24 && placed.some((p) => Math.hypot(p.x - px, p.y - py) < Math.min(tray.w, tray.h) * 0.05)) {
|
||||
px = x0 + (x1 - x0) * g();
|
||||
py = y0 + (y1 - y0) * g();
|
||||
tries++;
|
||||
}
|
||||
placed.push({ x: px, y: py, rotation: rotation ? (g() - 0.5) * 0.6 : 0 });
|
||||
}
|
||||
return placed;
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ import RushHourGame from './games/rushhour/RushHourGame.js';
|
|||
import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js';
|
||||
import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js';
|
||||
import ShiftGame from './games/shift/ShiftGame.js';
|
||||
import JigsawGame from './games/jigsaw/JigsawGame.js';
|
||||
import BlockFighterGame from './games/blockfighter/BlockFighterGame.js';
|
||||
import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js';
|
||||
import MahjongGame from './games/mahjong/MahjongGame.js';
|
||||
|
|
@ -184,6 +185,7 @@ const config = {
|
|||
HexsweeperGame,
|
||||
PuddingMonstersGame,
|
||||
ShiftGame,
|
||||
JigsawGame,
|
||||
BlockFighterGame,
|
||||
MahjongMatchGame,
|
||||
MahjongGame,
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame', freecell: 'FreecellGame', rushhour: 'RushHourGame', hexsweeper: 'HexsweeperGame', puddingmonsters: 'PuddingMonstersGame', shift: 'ShiftGame', blockfighter: 'BlockFighterGame', mahjongmatch: 'MahjongMatchGame', mahjong: 'MahjongGame', jewelquest: 'JewelQuestGame', zuma: 'ZumaGame', bejeweled: 'BejeweledGame', minimotorways: 'MiniMotorwaysGame', slots: 'SlotsGame', cribbage: 'CribbageGame', canasta: 'CanastaGame', dotlink: 'DotLinkGame', '2048': '2048Game', rummikub: 'RummikubGame', ginrummy: 'GinRummyGame', risk: 'RiskGame', geniussquare: 'GeniusSquareGame', katamino: 'KataminoGame', bookwork: 'BookworkGame', paigow: 'PaiGowPokerGame', spireclimb: 'SpireClimbGame', azul: 'AzulGame', jumble: 'JumbleGame', dungeonboss: 'DungeonBossGame', swdbg: 'SWDBGGame', balatro: 'BalatroGame', peggle: 'PeggleGame', coloradodefense: 'ColoradoDefenseGame', starcontrol: 'StarControlGame', civilization: 'CivilizationGame', tempest: 'TempestGame', superkart: 'SuperKartGame', advancewars: 'AdvanceWarsGame', tetrisattack: 'TetrisAttackGame', totalannihilation: 'TotalAnnihilationGame', bloxorz: 'BloxorzGame', gootower: 'GooTowerGame', excitebike: 'ExcitebikeGame', mastervega: 'MasterOfVegaGame', wolfenstein: 'WolfensteinGame', pipepuzzle: 'PipePuzzleGame', tents: 'TentsGame', jigsaw: 'jigsaw-game' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
const sceneKey = slugDispatch[this.game.slug];
|
||||
const startData = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue