diff --git a/public/assets/images/mahjong/bamboo1.png b/public/assets/images/mahjong/bamboo1.png new file mode 100644 index 0000000..a209010 Binary files /dev/null and b/public/assets/images/mahjong/bamboo1.png differ diff --git a/public/assets/images/mahjong/bamboo2.png b/public/assets/images/mahjong/bamboo2.png new file mode 100644 index 0000000..17753cb Binary files /dev/null and b/public/assets/images/mahjong/bamboo2.png differ diff --git a/public/assets/images/mahjong/bamboo3.png b/public/assets/images/mahjong/bamboo3.png new file mode 100644 index 0000000..334d915 Binary files /dev/null and b/public/assets/images/mahjong/bamboo3.png differ diff --git a/public/assets/images/mahjong/bamboo4.png b/public/assets/images/mahjong/bamboo4.png new file mode 100644 index 0000000..6b0c077 Binary files /dev/null and b/public/assets/images/mahjong/bamboo4.png differ diff --git a/public/assets/images/mahjong/bamboo5.png b/public/assets/images/mahjong/bamboo5.png new file mode 100644 index 0000000..fde737f Binary files /dev/null and b/public/assets/images/mahjong/bamboo5.png differ diff --git a/public/assets/images/mahjong/bamboo6.png b/public/assets/images/mahjong/bamboo6.png new file mode 100644 index 0000000..f421982 Binary files /dev/null and b/public/assets/images/mahjong/bamboo6.png differ diff --git a/public/assets/images/mahjong/bamboo7.png b/public/assets/images/mahjong/bamboo7.png new file mode 100644 index 0000000..16be0c8 Binary files /dev/null and b/public/assets/images/mahjong/bamboo7.png differ diff --git a/public/assets/images/mahjong/bamboo8.png b/public/assets/images/mahjong/bamboo8.png new file mode 100644 index 0000000..a305c4d Binary files /dev/null and b/public/assets/images/mahjong/bamboo8.png differ diff --git a/public/assets/images/mahjong/bamboo9.png b/public/assets/images/mahjong/bamboo9.png new file mode 100644 index 0000000..0457488 Binary files /dev/null and b/public/assets/images/mahjong/bamboo9.png differ diff --git a/public/assets/images/mahjong/chrysanthemum.png b/public/assets/images/mahjong/chrysanthemum.png new file mode 100644 index 0000000..2883e02 Binary files /dev/null and b/public/assets/images/mahjong/chrysanthemum.png differ diff --git a/public/assets/images/mahjong/circle1.png b/public/assets/images/mahjong/circle1.png new file mode 100644 index 0000000..3f1527c Binary files /dev/null and b/public/assets/images/mahjong/circle1.png differ diff --git a/public/assets/images/mahjong/circle2.png b/public/assets/images/mahjong/circle2.png new file mode 100644 index 0000000..d661a87 Binary files /dev/null and b/public/assets/images/mahjong/circle2.png differ diff --git a/public/assets/images/mahjong/circle3.png b/public/assets/images/mahjong/circle3.png new file mode 100644 index 0000000..46b9498 Binary files /dev/null and b/public/assets/images/mahjong/circle3.png differ diff --git a/public/assets/images/mahjong/circle4.png b/public/assets/images/mahjong/circle4.png new file mode 100644 index 0000000..8484f9b Binary files /dev/null and b/public/assets/images/mahjong/circle4.png differ diff --git a/public/assets/images/mahjong/circle5.png b/public/assets/images/mahjong/circle5.png new file mode 100644 index 0000000..02b7e9d Binary files /dev/null and b/public/assets/images/mahjong/circle5.png differ diff --git a/public/assets/images/mahjong/circle6.png b/public/assets/images/mahjong/circle6.png new file mode 100644 index 0000000..9a823cd Binary files /dev/null and b/public/assets/images/mahjong/circle6.png differ diff --git a/public/assets/images/mahjong/circle7.png b/public/assets/images/mahjong/circle7.png new file mode 100644 index 0000000..7a56f07 Binary files /dev/null and b/public/assets/images/mahjong/circle7.png differ diff --git a/public/assets/images/mahjong/circle8.png b/public/assets/images/mahjong/circle8.png new file mode 100644 index 0000000..94730ad Binary files /dev/null and b/public/assets/images/mahjong/circle8.png differ diff --git a/public/assets/images/mahjong/circle9.png b/public/assets/images/mahjong/circle9.png new file mode 100644 index 0000000..3509765 Binary files /dev/null and b/public/assets/images/mahjong/circle9.png differ diff --git a/public/assets/images/mahjong/fall.png b/public/assets/images/mahjong/fall.png new file mode 100644 index 0000000..8bd5f26 Binary files /dev/null and b/public/assets/images/mahjong/fall.png differ diff --git a/public/assets/images/mahjong/lotus.png b/public/assets/images/mahjong/lotus.png new file mode 100644 index 0000000..9f82dd4 Binary files /dev/null and b/public/assets/images/mahjong/lotus.png differ diff --git a/public/assets/images/mahjong/orchid.png b/public/assets/images/mahjong/orchid.png new file mode 100644 index 0000000..0479c3c Binary files /dev/null and b/public/assets/images/mahjong/orchid.png differ diff --git a/public/assets/images/mahjong/peony.png b/public/assets/images/mahjong/peony.png new file mode 100644 index 0000000..34e581d Binary files /dev/null and b/public/assets/images/mahjong/peony.png differ diff --git a/public/assets/images/mahjong/pinyin1.png b/public/assets/images/mahjong/pinyin1.png new file mode 100644 index 0000000..661da27 Binary files /dev/null and b/public/assets/images/mahjong/pinyin1.png differ diff --git a/public/assets/images/mahjong/pinyin10.png b/public/assets/images/mahjong/pinyin10.png new file mode 100644 index 0000000..c6b2b39 Binary files /dev/null and b/public/assets/images/mahjong/pinyin10.png differ diff --git a/public/assets/images/mahjong/pinyin11.png b/public/assets/images/mahjong/pinyin11.png new file mode 100644 index 0000000..158b10e Binary files /dev/null and b/public/assets/images/mahjong/pinyin11.png differ diff --git a/public/assets/images/mahjong/pinyin12.png b/public/assets/images/mahjong/pinyin12.png new file mode 100644 index 0000000..9fe92dc Binary files /dev/null and b/public/assets/images/mahjong/pinyin12.png differ diff --git a/public/assets/images/mahjong/pinyin13.png b/public/assets/images/mahjong/pinyin13.png new file mode 100644 index 0000000..55a1440 Binary files /dev/null and b/public/assets/images/mahjong/pinyin13.png differ diff --git a/public/assets/images/mahjong/pinyin14.png b/public/assets/images/mahjong/pinyin14.png new file mode 100644 index 0000000..1f69d56 Binary files /dev/null and b/public/assets/images/mahjong/pinyin14.png differ diff --git a/public/assets/images/mahjong/pinyin15.png b/public/assets/images/mahjong/pinyin15.png new file mode 100644 index 0000000..5d83449 Binary files /dev/null and b/public/assets/images/mahjong/pinyin15.png differ diff --git a/public/assets/images/mahjong/pinyin2.png b/public/assets/images/mahjong/pinyin2.png new file mode 100644 index 0000000..aabf82a Binary files /dev/null and b/public/assets/images/mahjong/pinyin2.png differ diff --git a/public/assets/images/mahjong/pinyin3.png b/public/assets/images/mahjong/pinyin3.png new file mode 100644 index 0000000..405eeee Binary files /dev/null and b/public/assets/images/mahjong/pinyin3.png differ diff --git a/public/assets/images/mahjong/pinyin4.png b/public/assets/images/mahjong/pinyin4.png new file mode 100644 index 0000000..4478a93 Binary files /dev/null and b/public/assets/images/mahjong/pinyin4.png differ diff --git a/public/assets/images/mahjong/pinyin5.png b/public/assets/images/mahjong/pinyin5.png new file mode 100644 index 0000000..c532d12 Binary files /dev/null and b/public/assets/images/mahjong/pinyin5.png differ diff --git a/public/assets/images/mahjong/pinyin6.png b/public/assets/images/mahjong/pinyin6.png new file mode 100644 index 0000000..d133010 Binary files /dev/null and b/public/assets/images/mahjong/pinyin6.png differ diff --git a/public/assets/images/mahjong/pinyin7.png b/public/assets/images/mahjong/pinyin7.png new file mode 100644 index 0000000..9efa80a Binary files /dev/null and b/public/assets/images/mahjong/pinyin7.png differ diff --git a/public/assets/images/mahjong/pinyin8.png b/public/assets/images/mahjong/pinyin8.png new file mode 100644 index 0000000..19178bc Binary files /dev/null and b/public/assets/images/mahjong/pinyin8.png differ diff --git a/public/assets/images/mahjong/pinyin9.png b/public/assets/images/mahjong/pinyin9.png new file mode 100644 index 0000000..e044dde Binary files /dev/null and b/public/assets/images/mahjong/pinyin9.png differ diff --git a/public/assets/images/mahjong/spring.png b/public/assets/images/mahjong/spring.png new file mode 100644 index 0000000..1a154bc Binary files /dev/null and b/public/assets/images/mahjong/spring.png differ diff --git a/public/assets/images/mahjong/summer.png b/public/assets/images/mahjong/summer.png new file mode 100644 index 0000000..0a5800a Binary files /dev/null and b/public/assets/images/mahjong/summer.png differ diff --git a/public/assets/images/mahjong/winter.png b/public/assets/images/mahjong/winter.png new file mode 100644 index 0000000..1df38d6 Binary files /dev/null and b/public/assets/images/mahjong/winter.png differ diff --git a/public/src/games/mahjongmatch/MahjongLogic.js b/public/src/games/mahjongmatch/MahjongLogic.js new file mode 100644 index 0000000..0cf7ca6 --- /dev/null +++ b/public/src/games/mahjongmatch/MahjongLogic.js @@ -0,0 +1,389 @@ +// Mahjong Match — pure board model for mahjong solitaire tile matching. +// No Phaser, no DOM. Self-contained so it can be unit-tested in Node. +// +// Coordinates are in half-tile units: a tile is 2 units wide × 2 units tall, +// so two tiles at the same z overlap iff |dx| < 2 and |dy| < 2. This allows +// the half-step offsets classic layouts need (e.g. the Turtle's apex tile +// straddles the four tiles beneath it). Layer z+1 rests on layer z. +// +// A tile is FREE when no tile covers it from above and at least one of its +// left/right sides is open. Matching free pairs of the same group are +// removed; the board is won when empty. +// +// Deals are generated by simulating the game in reverse — repeatedly pick +// two free positions from the full layout, remove them, and assign them the +// next tile pair. The removal order is itself a solution, so every deal is +// guaranteed winnable. + +// ── Tile faces ──────────────────────────────────────────────────────────────── +// `label` is the texture key for the overlay art (loaded in PreloadScene from +// /assets/images/mahjong). The White Dragon has no art — traditionally it is +// just an empty frame, which the scene draws procedurally (label: null). +// Tiles match when their `group` matches: every face is its own group except +// the four Flowers and four Seasons, which match within their family. + +// pinyin sheet index for character (萬) tiles 1..9 +const CHAR_LABEL = [13, 14, 15, 7, 8, 9, 10, 11, 12]; + +export const FACES = (() => { + const faces = []; + for (let n = 1; n <= 9; n++) { + faces.push({ id: `bamboo${n}`, group: `bamboo${n}`, label: `mahjong-bamboo${n}`, copies: 4 }); + } + for (let n = 1; n <= 9; n++) { + faces.push({ id: `circle${n}`, group: `circle${n}`, label: `mahjong-circle${n}`, copies: 4 }); + } + for (let n = 1; n <= 9; n++) { + faces.push({ id: `char${n}`, group: `char${n}`, label: `mahjong-pinyin${CHAR_LABEL[n - 1]}`, copies: 4 }); + } + faces.push({ id: 'wind-east', group: 'wind-east', label: 'mahjong-pinyin4', copies: 4 }); + faces.push({ id: 'wind-south', group: 'wind-south', label: 'mahjong-pinyin3', copies: 4 }); + faces.push({ id: 'wind-west', group: 'wind-west', label: 'mahjong-pinyin6', copies: 4 }); + faces.push({ id: 'wind-north', group: 'wind-north', label: 'mahjong-pinyin5', copies: 4 }); + faces.push({ id: 'dragon-red', group: 'dragon-red', label: 'mahjong-pinyin1', copies: 4 }); + faces.push({ id: 'dragon-green', group: 'dragon-green', label: 'mahjong-pinyin2', copies: 4 }); + faces.push({ id: 'dragon-white', group: 'dragon-white', label: null, copies: 4 }); + for (const f of ['orchid', 'peony', 'chrysanthemum', 'lotus']) { + faces.push({ id: f, group: 'flower', label: `mahjong-${f}`, copies: 1 }); + } + for (const s of ['spring', 'summer', 'fall', 'winter']) { + faces.push({ id: s, group: 'season', label: `mahjong-${s}`, copies: 1 }); + } + return faces; +})(); + +// ── Layouts ─────────────────────────────────────────────────────────────────── + +function grid(out, x0, y0, cols, rows, z) { + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) out.push({ x: x0 + 2 * c, y: y0 + 2 * r, z }); + } + return out; +} + +function dedupe(positions) { + const seen = new Set(); + const out = []; + for (const t of positions) { + const k = `${t.x},${t.y},${t.z}`; + if (!seen.has(k)) { seen.add(k); out.push(t); } + } + return out; +} + +// Easy warm-up: a 10×6 bed with a small raised terrace. 72 tiles. +function garden() { + const p = []; + grid(p, 0, 0, 10, 6, 0); + grid(p, 6, 3, 4, 3, 1); + return p; +} + +// A plus-sign of two crossing bars with a raised hub. 90 tiles. +function crossroads() { + let p = []; + grid(p, 0, 4, 14, 4, 0); + grid(p, 8, 0, 6, 8, 0); + p = dedupe(p); + grid(p, 10, 6, 4, 2, 1); + p.push({ x: 12, y: 7, z: 2 }, { x: 14, y: 7, z: 2 }); + return p; +} + +// Four shrinking tiers with a two-tile cap. 106 tiles. +function pyramid() { + const p = []; + grid(p, 0, 0, 10, 6, 0); + grid(p, 2, 1, 8, 4, 1); + grid(p, 4, 3, 6, 2, 2); + p.push({ x: 8, y: 4, z: 3 }, { x: 10, y: 4, z: 3 }); + return p; +} + +// Two stacked wings around a tall body. 140 tiles. +function butterfly() { + const p = []; + grid(p, 0, 2, 6, 6, 0); // left wing + grid(p, 18, 2, 6, 6, 0); // right wing + grid(p, 13, 0, 2, 8, 0); // body + grid(p, 2, 4, 4, 4, 1); + grid(p, 20, 4, 4, 4, 1); + grid(p, 13, 2, 2, 6, 1); + grid(p, 4, 6, 2, 2, 2); + grid(p, 22, 6, 2, 2, 2); + return p; +} + +// A solid courtyard ringed by a raised rampart. 144 tiles. +function fortress() { + const p = []; + grid(p, 0, 0, 14, 8, 0); + grid(p, 2, 2, 12, 1, 1); // north wall + grid(p, 2, 12, 12, 1, 1); // south wall + grid(p, 2, 4, 1, 4, 1); // west wall + grid(p, 24, 4, 1, 4, 1); // east wall + return p; +} + +// The classic 144-tile Turtle. +function turtle() { + const p = []; + const widths = [12, 8, 10, 12, 12, 10, 8, 12]; + widths.forEach((w, i) => grid(p, 14 - w, 2 * i, w, 1, 0)); + p.push({ x: 0, y: 7, z: 0 }); // left flipper + p.push({ x: 26, y: 7, z: 0 }, { x: 28, y: 7, z: 0 }); // right flippers + grid(p, 8, 2, 6, 6, 1); + grid(p, 10, 4, 4, 4, 2); + grid(p, 12, 6, 2, 2, 3); + p.push({ x: 13, y: 7, z: 4 }); // apex, straddling + return p; +} + +export const LAYOUTS = { + garden: { key: 'garden', name: 'Garden', desc: 'A gentle open spread', positions: garden() }, + crossroads: { key: 'crossroads', name: 'Crossroads', desc: 'Two bars meet at a hub', positions: crossroads() }, + pyramid: { key: 'pyramid', name: 'Pyramid', desc: 'Four tiers to the top', positions: pyramid() }, + butterfly: { key: 'butterfly', name: 'Butterfly', desc: 'Stacked wings, tall body', positions: butterfly() }, + fortress: { key: 'fortress', name: 'Fortress', desc: 'A walled courtyard', positions: fortress() }, + turtle: { key: 'turtle', name: 'Turtle', desc: 'The timeless classic', positions: turtle() }, +}; + +export const LAYOUT_ORDER = ['garden', 'crossroads', 'pyramid', 'butterfly', 'fortress', 'turtle']; + +export function layoutBounds(positions) { + let maxX = 0, maxY = 0, maxZ = 0; + for (const t of positions) { + if (t.x > maxX) maxX = t.x; + if (t.y > maxY) maxY = t.y; + if (t.z > maxZ) maxZ = t.z; + } + return { spanX: maxX + 2, spanY: maxY + 2, maxZ }; +} + +// ── Geometry ────────────────────────────────────────────────────────────────── + +export function overlapsXY(a, b) { + return Math.abs(a.x - b.x) < 2 && Math.abs(a.y - b.y) < 2; +} + +// Per-position index lists: tiles directly above, and side neighbours that +// block the left/right edge. Computed once per game so free checks are O(k). +function buildAdjacency(positions) { + const n = positions.length; + const above = Array.from({ length: n }, () => []); + const left = Array.from({ length: n }, () => []); + const right = Array.from({ length: n }, () => []); + for (let i = 0; i < n; i++) { + const a = positions[i]; + for (let j = 0; j < n; j++) { + if (i === j) continue; + const b = positions[j]; + if (b.z === a.z + 1 && overlapsXY(a, b)) above[i].push(j); + if (b.z === a.z && Math.abs(b.y - a.y) < 2) { + if (b.x === a.x - 2) left[i].push(j); + if (b.x === a.x + 2) right[i].push(j); + } + } + } + return { above, left, right }; +} + +function freeInMask(adj, alive, i) { + if (!alive[i]) return false; + for (const j of adj.above[i]) if (alive[j]) return false; + let L = false, R = false; + for (const j of adj.left[i]) if (alive[j]) { L = true; break; } + if (L) for (const j of adj.right[i]) if (alive[j]) { R = true; break; } + return !(L && R); +} + +// ── Deal generation ─────────────────────────────────────────────────────────── + +function shuffle(arr) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + +// 72 matchable pairs from the full 144-tile set. Normal faces pair with their +// own duplicates; the one-of-each Flowers and Seasons pair within their family. +export function buildPairPool() { + const byGroup = new Map(); + for (const f of FACES) { + if (!byGroup.has(f.group)) byGroup.set(f.group, []); + for (let c = 0; c < f.copies; c++) byGroup.get(f.group).push(f); + } + const pairs = []; + for (const members of byGroup.values()) { + shuffle(members); + for (let k = 0; k + 1 < members.length; k += 2) pairs.push([members[k], members[k + 1]]); + } + return pairs; +} + +// Simulate removing free pairs until the masked board is empty. Returns the +// removal order (a valid solution) or null when the random walk strands tiles. +function reverseRemovalOrder(adj, mask) { + const alive = mask.slice(); + let remaining = 0; + for (const a of alive) if (a) remaining++; + const order = []; + while (remaining > 0) { + const free = []; + for (let i = 0; i < alive.length; i++) if (freeInMask(adj, alive, i)) free.push(i); + if (free.length < 2) return null; + const ai = Math.floor(Math.random() * free.length); + let bi = Math.floor(Math.random() * (free.length - 1)); + if (bi >= ai) bi++; + const a = free[ai], b = free[bi]; + order.push([a, b]); + alive[a] = false; + alive[b] = false; + remaining -= 2; + } + return order; +} + +// Assign faces to every position so the board is solvable. Falls back to a +// blind shuffle (very rare — only if 300 random removal walks all strand). +function dealFaces(positions, adj) { + const n = positions.length; + const fullMask = positions.map(() => true); + for (let attempt = 0; attempt < 300; attempt++) { + const order = reverseRemovalOrder(adj, fullMask); + if (!order) continue; + const pairs = shuffle(buildPairPool()).slice(0, n / 2); + const faces = new Array(n); + order.forEach(([a, b], k) => { faces[a] = pairs[k][0]; faces[b] = pairs[k][1]; }); + return { faces, solvable: true }; + } + const pairs = shuffle(buildPairPool()).slice(0, n / 2); + const idx = shuffle(positions.map((_, i) => i)); + const faces = new Array(n); + pairs.forEach(([fa, fb], k) => { faces[idx[2 * k]] = fa; faces[idx[2 * k + 1]] = fb; }); + return { faces, solvable: false }; +} + +// ── Game state ──────────────────────────────────────────────────────────────── + +export function newGame(layoutKey) { + const layout = LAYOUTS[layoutKey] ?? LAYOUTS.garden; + const positions = layout.positions; + const adj = buildAdjacency(positions); + const { faces } = dealFaces(positions, adj); + return { + layoutKey: layout.key, + positions, + adj, + faces, + alive: positions.map(() => true), + remaining: positions.length, + state: 'playing', // 'playing' | 'won' + }; +} + +export function isFree(g, i) { + return freeInMask(g.adj, g.alive, i); +} + +export function canMatch(g, i, j) { + return i !== j && g.faces[i].group === g.faces[j].group; +} + +// Remove a matching free pair. Returns true if the move was legal. +export function removePair(g, i, j) { + if (g.state !== 'playing') return false; + if (!canMatch(g, i, j) || !isFree(g, i) || !isFree(g, j)) return false; + g.alive[i] = false; + g.alive[j] = false; + g.remaining -= 2; + if (g.remaining === 0) g.state = 'won'; + return true; +} + +// Every currently playable move as [i, j] index pairs (used for the hint +// button, the moves-available counter, and stuck detection). +export function findMoves(g) { + const byGroup = new Map(); + for (let i = 0; i < g.positions.length; i++) { + if (!isFree(g, i)) continue; + const grp = g.faces[i].group; + if (!byGroup.has(grp)) byGroup.set(grp, []); + byGroup.get(grp).push(i); + } + const moves = []; + for (const members of byGroup.values()) { + for (let a = 0; a < members.length; a++) { + for (let b = a + 1; b < members.length; b++) moves.push([members[a], members[b]]); + } + } + return moves; +} + +// Redistribute the remaining faces over the remaining positions so the rest +// of the board is solvable again. Returns false when no solvable arrangement +// was found (the tiles are then shuffled blindly — e.g. two tiles stacked +// directly on one another can never be freed, no matter the arrangement). +export function reshuffleRemaining(g) { + if (g.state !== 'playing' || g.remaining < 2) return true; + + // Pair the surviving faces by group. Group counts stay even because every + // removal takes two tiles of one group. + const byGroup = new Map(); + const aliveIdx = []; + for (let i = 0; i < g.positions.length; i++) { + if (!g.alive[i]) continue; + aliveIdx.push(i); + const grp = g.faces[i].group; + if (!byGroup.has(grp)) byGroup.set(grp, []); + byGroup.get(grp).push(g.faces[i]); + } + const pairs = []; + const leftovers = []; + for (const members of byGroup.values()) { + shuffle(members); + for (let k = 0; k + 1 < members.length; k += 2) pairs.push([members[k], members[k + 1]]); + if (members.length % 2) leftovers.push(members[members.length - 1]); + } + for (let k = 0; k + 1 < leftovers.length; k += 2) pairs.push([leftovers[k], leftovers[k + 1]]); + + for (let attempt = 0; attempt < 300; attempt++) { + const order = reverseRemovalOrder(g.adj, g.alive); + if (!order) continue; + order.forEach(([a, b], k) => { g.faces[a] = pairs[k][0]; g.faces[b] = pairs[k][1]; }); + return true; + } + + shuffle(aliveIdx); + pairs.forEach(([fa, fb], k) => { g.faces[aliveIdx[2 * k]] = fa; g.faces[aliveIdx[2 * k + 1]] = fb; }); + return false; +} + +// ── Layout validation (used by the verify script) ───────────────────────────── + +export function validateLayout(positions) { + const errors = []; + if (positions.length % 2) errors.push(`odd tile count ${positions.length}`); + const seen = new Set(); + for (const t of positions) { + const k = `${t.x},${t.y},${t.z}`; + if (seen.has(k)) errors.push(`duplicate position ${k}`); + seen.add(k); + } + for (let i = 0; i < positions.length; i++) { + const a = positions[i]; + for (let j = i + 1; j < positions.length; j++) { + const b = positions[j]; + if (a.z === b.z && overlapsXY(a, b)) { + errors.push(`overlap at z${a.z}: (${a.x},${a.y}) vs (${b.x},${b.y})`); + } + } + if (a.z > 0) { + const supported = positions.some((b) => b.z === a.z - 1 && overlapsXY(a, b)); + if (!supported) errors.push(`unsupported tile (${a.x},${a.y},${a.z})`); + } + } + return errors; +} diff --git a/public/src/games/mahjongmatch/MahjongMatchGame.js b/public/src/games/mahjongmatch/MahjongMatchGame.js new file mode 100644 index 0000000..5ae637e --- /dev/null +++ b/public/src/games/mahjongmatch/MahjongMatchGame.js @@ -0,0 +1,542 @@ +import * as Phaser from 'phaser'; +import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js'; +import { Button } from '../../ui/Button.js'; +import { MusicPlayer } from '../../ui/MusicPlayer.js'; +import { playSound, SFX } from '../../ui/Sounds.js'; +import { api } from '../../services/api.js'; +import { + LAYOUTS, LAYOUT_ORDER, layoutBounds, + newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining, +} from './MahjongLogic.js'; + +// Deep-green felt with ivory tiles — the classic mahjong table look. +const FELT = 0x0e2a1c; +const FACE = 0xf6efdb; +const FACE_HOVER = 0xfff9e8; +const FACE_PICKED = 0xffdf9e; +const FACE_EDGE = 0x8d7c52; +const PICK_EDGE = 0xff9d00; +const SIDE = 0xb59c66; +const DRAGON_BLUE = 0x3f6bb5; +const DIM_TINT = 0x8f8f8f; + +// Stacked-tile shades for the layout previews, indexed by z. +const PREVIEW_Z = [0x9c8f6e, 0xb3a47e, 0xcab98e, 0xe0cf9f, 0xf6e6b0]; + +// Label art is 128×178; keep its aspect when fitting it onto a tile face. +const LABEL_W = 128; +const LABEL_H = 178; + +const D = { bg: -2, ui: 30 }; + +export default class MahjongMatchGame extends Phaser.Scene { + constructor() { super('MahjongMatchGame'); } + + init(data) { + this.gameDef = data.game ?? { slug: 'mahjongmatch', name: 'Mahjong Match' }; + this.view = 'select'; + this.g = null; + this.layoutKey = null; + this.tileObjs = []; // tileObjs[i] = { container, gfx, label, hover } + this.selected = null; + this.hintPair = null; + this.hintTimer = null; + this.elapsed = 0; + this.timerEvent = null; + this.overlay = null; + this.overlayUp = false; + this.tilesText = null; + this.movesText = null; + this.timerText = null; + } + + create() { + try { + const music = this.cache.json.get('music'); + if (music?.tracks) new MusicPlayer(this, music.tracks); + } catch (_) { /* optional */ } + + this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, FELT).setDepth(D.bg); + this.layer = this.add.container(0, 0); + this.showLayoutSelect(); + } + + clearLayer() { + if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; } + if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; } + if (this.overlay) { this.overlay.destroy(true); this.overlay = null; } + this.layer.removeAll(true); + this.tileObjs = []; + this.selected = null; + this.hintPair = null; + this.tilesText = null; + this.movesText = null; + this.timerText = null; + } + + // ── Layout select ───────────────────────────────────────────────────────────── + + showLayoutSelect() { + this.view = 'select'; + this.overlayUp = false; + this.clearLayer(); + const cx = GAME_WIDTH / 2; + + const title = this.add.text(cx, 100, 'MAHJONG MATCH', { + fontFamily: 'Righteous', fontSize: '78px', color: COLORS.goldHex, + }).setOrigin(0.5); + const sub = this.add.text(cx, 178, 'Clear the board by matching free pairs. A tile is free when nothing rests on it and a side is open.', { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0.5); + this.layer.add([title, sub]); + + const CARD_W = 480; + const CARD_H = 310; + const GAP_X = 60; + const ROW_Y = [420, 770]; + const totalW = 3 * CARD_W + 2 * GAP_X; + const left = cx - totalW / 2 + CARD_W / 2; + + LAYOUT_ORDER.forEach((key, i) => { + const layout = LAYOUTS[key]; + const x = left + (i % 3) * (CARD_W + GAP_X); + const y = ROW_Y[Math.floor(i / 3)]; + + const card = this.add.rectangle(x, y, CARD_W, CARD_H, 0x143523) + .setStrokeStyle(3, COLORS.gold, 0.55); + this.layer.add(card); + + const preview = this.add.graphics(); + this._drawLayoutPreview(preview, layout, x, y - 60, 320, 150); + this.layer.add(preview); + + const name = this.add.text(x, y + 52, layout.name, { + fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, + }).setOrigin(0.5); + const info = this.add.text(x, y + 96, `${layout.positions.length} tiles · ${layout.desc}`, { + fontFamily: '"Julius Sans One"', fontSize: '21px', color: COLORS.mutedHex, + }).setOrigin(0.5); + const best = this._bestFor(key); + const bestLbl = this.add.text(x, y + 130, best !== null ? `Best: ${this._fmtTime(best)}` : 'Not cleared yet', { + fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.layer.add([name, info, bestLbl]); + + card.setInteractive({ useHandCursor: true }); + card.on('pointerover', () => card.setStrokeStyle(5, COLORS.gold, 1)); + card.on('pointerout', () => card.setStrokeStyle(3, COLORS.gold, 0.55)); + card.on('pointerup', () => this.startGame(key)); + }); + + const back = new Button(this, cx, GAME_HEIGHT - 60, 'Back', () => this.scene.start('GameMenu'), + { variant: 'ghost', width: 220, height: 56, fontSize: 24 }); + this.layer.add(back); + } + + // Top-down miniature of a layout: one shaded rect per tile, lighter per layer. + _drawLayoutPreview(gfx, layout, cx, cy, boxW, boxH) { + const { spanX, spanY, maxZ } = layoutBounds(layout.positions); + const mhw = Math.min(boxW / (spanX + maxZ), boxH / (spanY * 1.33)); + const mhh = mhw * 1.33; + const lift = mhw * 0.45; + const ox = cx - (spanX * mhw - maxZ * lift) / 2; + const oy = cy - (spanY * mhh - maxZ * lift) / 2; + + const sorted = [...layout.positions].sort((a, b) => (a.z - b.z) || (a.y - b.y) || (a.x - b.x)); + for (const t of sorted) { + const px = ox + t.x * mhw - t.z * lift; + const py = oy + t.y * mhh - t.z * lift; + gfx.fillStyle(PREVIEW_Z[Math.min(t.z, PREVIEW_Z.length - 1)], 1); + gfx.fillRect(px, py, 2 * mhw, 2 * mhh); + gfx.lineStyle(1, 0x241e12, 0.9); + gfx.strokeRect(px, py, 2 * mhw, 2 * mhh); + } + } + + _bestFor(layoutKey) { + const v = parseInt(localStorage.getItem(`mahjongmatch-best-${layoutKey}`), 10); + return isNaN(v) ? null : v; + } + + _fmtTime(seconds) { + const m = Math.floor(seconds / 60); + const s = String(seconds % 60).padStart(2, '0'); + return `${m}:${s}`; + } + + // ── Gameplay ────────────────────────────────────────────────────────────────── + + startGame(layoutKey) { + this.view = 'play'; + this.layoutKey = layoutKey; + this.g = newGame(layoutKey); + this.selected = null; + this.hintPair = null; + this.elapsed = 0; + this.overlayUp = false; + + this.clearLayer(); + this._computeLayout(); + this._buildTiles(); + this._drawHud(); + this._startTimer(); + } + + // Fit the layout into the area right of the button strip. + _computeLayout() { + const { spanX, spanY, maxZ } = layoutBounds(this.g.positions); + const LEFT = 310; + const TOP = 160; + const availW = GAME_WIDTH - 60 - LEFT; + const availH = GAME_HEIGHT - 40 - TOP; + + this.halfW = Math.min(availW / (spanX + 1), availH / ((spanY + 1) * 1.33), 50); + this.halfH = this.halfW * 1.33; + this.tileW = this.halfW * 2; + this.tileH = this.halfH * 2; + this.thick = Math.max(5, Math.round(this.halfW * 0.20)); + + // Higher layers shift up-left by `thick`; center the overall silhouette. + const visW = spanX * this.halfW + (maxZ + 1) * this.thick; + const visH = spanY * this.halfH + (maxZ + 1) * this.thick; + this.originX = LEFT + (availW - visW) / 2 + maxZ * this.thick; + this.originY = TOP + (availH - visH) / 2 + maxZ * this.thick; + } + + _tileScreenPos(i) { + const t = this.g.positions[i]; + return { + x: this.originX + (t.x + 1) * this.halfW - t.z * this.thick, + y: this.originY + (t.y + 1) * this.halfH - t.z * this.thick, + }; + } + + _buildTiles() { + this.tileObjs = []; + const order = this.g.positions + .map((_, i) => i) + .filter((i) => this.g.alive[i]) + .sort((a, b) => { + const pa = this.g.positions[a], pb = this.g.positions[b]; + return (pa.z - pb.z) || (pa.y - pb.y) || (pa.x - pb.x); + }); + + for (const i of order) { + const { x, y } = this._tileScreenPos(i); + const container = this.add.container(x, y); + const gfx = this.add.graphics(); + container.add(gfx); + + let label = null; + const face = this.g.faces[i]; + if (face.label && this.textures.exists(face.label)) { + const scale = Math.min((this.tileW * 0.80) / LABEL_W, (this.tileH * 0.82) / LABEL_H); + label = this.add.image(0, 0, face.label).setScale(scale); + container.add(label); + } + + container.setSize(this.tileW, this.tileH); + container.setInteractive({ useHandCursor: true }); + container.on('pointerover', () => { this.tileObjs[i].hover = true; this._redrawTile(i); }); + container.on('pointerout', () => { this.tileObjs[i].hover = false; this._redrawTile(i); }); + container.on('pointerup', () => this.onTileClick(i)); + + this.layer.add(container); + this.tileObjs[i] = { container, gfx, label, hover: false }; + this._redrawTile(i); + } + } + + _redrawTile(i) { + const o = this.tileObjs[i]; + if (!o || !this.g.alive[i]) return; + const w = this.tileW, h = this.tileH, t = this.thick, r = Math.max(4, t); + const free = isFree(this.g, i); + const picked = this.selected === i || (this.hintPair?.includes(i) ?? false); + + const gfx = o.gfx; + gfx.clear(); + + // Extruded body toward the lower-right, then the top face. + gfx.fillStyle(SIDE, 1); + gfx.fillRoundedRect(-w / 2 + t, -h / 2 + t, w, h, r); + gfx.fillStyle(picked ? FACE_PICKED : (o.hover && free ? FACE_HOVER : FACE), 1); + gfx.fillRoundedRect(-w / 2, -h / 2, w, h, r); + gfx.lineStyle(picked ? 3 : 2, picked ? PICK_EDGE : FACE_EDGE, 1); + gfx.strokeRoundedRect(-w / 2, -h / 2, w, h, r); + + // The White Dragon face is traditionally an empty blue frame. + if (this.g.faces[i].id === 'dragon-white') { + gfx.lineStyle(Math.max(3, t * 0.5), DRAGON_BLUE, 0.95); + gfx.strokeRoundedRect(-w * 0.30, -h * 0.32, w * 0.60, h * 0.64, 6); + } + + if (!free) { + gfx.fillStyle(0x000000, 0.30); + gfx.fillRoundedRect(-w / 2, -h / 2, w, h, r); + } + if (o.label) { + if (free) o.label.clearTint(); else o.label.setTint(DIM_TINT); + } + } + + _refreshTiles() { + for (let i = 0; i < this.g.positions.length; i++) { + if (this.g.alive[i]) this._redrawTile(i); + } + } + + _rebuildTiles() { + for (const o of this.tileObjs) o?.container?.destroy(true); + this._buildTiles(); + } + + // ── HUD ─────────────────────────────────────────────────────────────────────── + + _drawHud() { + const cx = GAME_WIDTH / 2; + const layout = LAYOUTS[this.layoutKey]; + + const title = this.add.text(40, 64, 'MAHJONG MATCH', { + fontFamily: 'Righteous', fontSize: '40px', color: COLORS.goldHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + const diff = this.add.text(40, 106, layout.name, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.mutedHex, + }).setOrigin(0, 0.5).setDepth(D.ui); + this.layer.add([title, diff]); + + this.tilesText = this.add.text(cx, 56, '', { + fontFamily: 'Righteous', fontSize: '38px', color: COLORS.textHex, + }).setOrigin(0.5).setDepth(D.ui); + this.movesText = this.add.text(cx, 100, '', { + fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex, + }).setOrigin(0.5).setDepth(D.ui); + this.timerText = this.add.text(GAME_WIDTH - 50, 80, '', { + fontFamily: 'Righteous', fontSize: '34px', color: COLORS.textHex, + }).setOrigin(1, 0.5).setDepth(D.ui); + this.layer.add([this.tilesText, this.movesText, this.timerText]); + + const stripCx = 150; + const BTN_W = 220, BTN_H = 58, BTN_GAP = 16; + let btnY = GAME_HEIGHT / 2 - (4 * BTN_H + 3 * BTN_GAP) / 2; + + const hint = new Button(this, stripCx, btnY, 'Hint', () => this.showHint(), + { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + btnY += BTN_H + BTN_GAP; + const shuffleB = new Button(this, stripCx, btnY, 'Shuffle', () => this.doShuffle(), + { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + btnY += BTN_H + BTN_GAP; + const restart = new Button(this, stripCx, btnY, 'New Game', () => this.startGame(this.layoutKey), + { width: BTN_W, height: BTN_H, fontSize: 22 }); + btnY += BTN_H + BTN_GAP; + const layouts = new Button(this, stripCx, btnY, 'Layouts', () => this.showLayoutSelect(), + { width: BTN_W, height: BTN_H, fontSize: 22, variant: 'ghost' }); + this.layer.add([hint, shuffleB, restart, layouts]); + + this._updateHud(); + } + + _updateHud() { + if (this.tilesText) this.tilesText.setText(`Tiles: ${this.g.remaining}`); + if (this.movesText) this.movesText.setText(`Moves available: ${findMoves(this.g).length}`); + if (this.timerText) this.timerText.setText(`⏱ ${this._fmtTime(this.elapsed)}`); + } + + _startTimer() { + this.timerEvent = this.time.addEvent({ + delay: 1000, loop: true, + callback: () => { + this.elapsed++; + if (this.timerText) this.timerText.setText(`⏱ ${this._fmtTime(this.elapsed)}`); + }, + }); + } + + // ── Input ───────────────────────────────────────────────────────────────────── + + onTileClick(i) { + if (this.overlayUp || this.g.state !== 'playing' || !this.g.alive[i]) return; + + if (!isFree(this.g, i)) { + this._shakeTile(i); + return; + } + + if (this.selected === i) { + this.selected = null; + this._redrawTile(i); + return; + } + + if (this.selected !== null && canMatch(this.g, this.selected, i)) { + const a = this.selected; + this.selected = null; + this._clearHint(); + if (!removePair(this.g, a, i)) return; + playSound(this, SFX.CARD_PLACE); + this._animateRemoval(a); + this._animateRemoval(i); + this._refreshTiles(); + this._updateHud(); + if (this.g.state === 'won') { + this._onWin(); + } else if (findMoves(this.g).length === 0) { + this._onStuck(true); + } + return; + } + + const prev = this.selected; + this.selected = i; + playSound(this, SFX.PIECE_CLICK); + if (prev !== null) this._redrawTile(prev); + this._redrawTile(i); + } + + _shakeTile(i) { + const o = this.tileObjs[i]; + if (!o) return; + const { x } = this._tileScreenPos(i); + this.tweens.add({ + targets: o.container, x: x + 6, duration: 45, yoyo: true, repeat: 2, + onComplete: () => o.container.setX(x), + }); + } + + _animateRemoval(i) { + const o = this.tileObjs[i]; + if (!o) return; + this.tweens.add({ + targets: o.container, + alpha: 0, y: o.container.y - 26, scaleX: 0.7, scaleY: 0.7, + duration: 230, ease: 'Quad.easeIn', + onComplete: () => o.container.setVisible(false), + }); + } + + // ── Hint & shuffle ──────────────────────────────────────────────────────────── + + showHint() { + if (this.overlayUp || this.g.state !== 'playing') return; + const moves = findMoves(this.g); + if (!moves.length) return; + this._clearHint(); + this.hintPair = moves[Math.floor(Math.random() * moves.length)]; + playSound(this, SFX.CARD_SHOW); + for (const i of this.hintPair) this._redrawTile(i); + this.hintTimer = this.time.delayedCall(1300, () => this._clearHint()); + } + + _clearHint() { + if (this.hintTimer) { this.hintTimer.remove(false); this.hintTimer = null; } + const pair = this.hintPair; + this.hintPair = null; + if (pair) for (const i of pair) if (this.g.alive[i]) this._redrawTile(i); + } + + doShuffle() { + if (this.overlayUp || this.g.state !== 'playing' || this.g.remaining < 2) return; + this.selected = null; + this._clearHint(); + const solvable = reshuffleRemaining(this.g); + playSound(this, SFX.CARD_SHUFFLE); + this._rebuildTiles(); + this._updateHud(); + if (findMoves(this.g).length === 0) this._onStuck(solvable); + return solvable; + } + + // ── Overlays ────────────────────────────────────────────────────────────────── + + _dismissOverlay() { + if (this.overlay) { this.overlay.destroy(true); this.overlay = null; } + this.overlayUp = false; + } + + _makeOverlayPanel(strokeColor) { + const cx = GAME_WIDTH / 2, cy = GAME_HEIGHT / 2; + this.overlay = this.add.container(0, 0); + this.overlayUp = true; + + const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.62).setInteractive(); + const panel = this.add.graphics(); + panel.fillStyle(COLORS.panel, 0.98); + panel.fillRoundedRect(cx - 340, cy - 210, 680, 420, 20); + panel.lineStyle(3, strokeColor, 1); + panel.strokeRoundedRect(cx - 340, cy - 210, 680, 420, 20); + this.overlay.add([dim, panel]); + return { cx, cy }; + } + + _onWin() { + if (this.timerEvent) { this.timerEvent.remove(false); this.timerEvent = null; } + playSound(this, SFX.VICTORY_SHORT); + + const lsKey = `mahjongmatch-best-${this.layoutKey}`; + const prev = this._bestFor(this.layoutKey); + const newBest = prev === null || this.elapsed < prev; + if (newBest) localStorage.setItem(lsKey, String(this.elapsed)); + + api.post('/history/single-player', { + slug: 'mahjongmatch', score: this.elapsed, opponentScores: [], result: 'win', + }).catch(() => { /* best effort */ }); + + const { cx, cy } = this._makeOverlayPanel(0x45d17a); + const title = this.add.text(cx, cy - 130, 'Board Cleared!', { + fontFamily: 'Righteous', fontSize: '68px', color: '#45d17a', + }).setOrigin(0.5); + const stat = this.add.text(cx, cy - 40, `You cleared the ${LAYOUTS[this.layoutKey].name} in ${this._fmtTime(this.elapsed)}.`, { + fontFamily: '"Julius Sans One"', fontSize: '28px', color: COLORS.textHex, + }).setOrigin(0.5); + const bestMsg = newBest && prev !== null + ? `★ New Best! (was ${this._fmtTime(prev)})` + : `Best: ${this._fmtTime(newBest ? this.elapsed : prev)}`; + const best = this.add.text(cx, cy + 16, bestMsg, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.goldHex, + }).setOrigin(0.5); + this.overlay.add([title, stat, best]); + + const again = new Button(this, cx - 170, cy + 120, 'Play Again', () => this.startGame(this.layoutKey), + { width: 280, height: 60, fontSize: 26 }); + const layouts = new Button(this, cx + 170, cy + 120, 'Layouts', () => this.showLayoutSelect(), + { width: 280, height: 60, fontSize: 26, variant: 'ghost' }); + this.overlay.add([again, layouts]); + } + + // `shuffleHelps` is false when the remaining tiles cannot be rearranged into + // a solvable board (e.g. a pair stacked directly on top of each other). + _onStuck(shuffleHelps) { + const { cx, cy } = this._makeOverlayPanel(COLORS.danger); + const title = this.add.text(cx, cy - 130, 'No Moves Left', { + fontFamily: 'Righteous', fontSize: '64px', color: COLORS.dangerHex, + }).setOrigin(0.5); + const msg = shuffleHelps + ? `${this.g.remaining} tiles remain, but no free pair matches.\nShuffle the remaining tiles to keep going.` + : `${this.g.remaining} tiles remain, and no arrangement of them\ncan be cleared. Start a new game.`; + const stat = this.add.text(cx, cy - 36, msg, { + fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex, align: 'center', + }).setOrigin(0.5); + this.overlay.add([title, stat]); + + let bx = cx - 220; + if (shuffleHelps) { + const shuffleB = new Button(this, bx, cy + 120, 'Shuffle', () => { this._dismissOverlay(); this.doShuffle(); }, + { width: 200, height: 60, fontSize: 24 }); + this.overlay.add(shuffleB); + bx += 220; + } + const again = new Button(this, bx, cy + 120, 'New Game', () => this.startGame(this.layoutKey), + { width: 200, height: 60, fontSize: 24, variant: shuffleHelps ? 'ghost' : undefined }); + bx += 220; + const giveUp = new Button(this, bx, cy + 120, 'Give Up', () => this._giveUp(), + { width: 200, height: 60, fontSize: 24, variant: 'ghost' }); + this.overlay.add([again, giveUp]); + } + + _giveUp() { + api.post('/history/single-player', { + slug: 'mahjongmatch', score: this.elapsed, opponentScores: [], result: 'loss', + }).catch(() => { /* best effort */ }); + this.showLayoutSelect(); + } +} diff --git a/public/src/main.js b/public/src/main.js index f1da53a..a45f157 100644 --- a/public/src/main.js +++ b/public/src/main.js @@ -66,6 +66,7 @@ import HexsweeperGame from './games/hexsweeper/HexsweeperGame.js'; import PuddingMonstersGame from './games/puddingmonsters/PuddingMonstersGame.js'; import ShiftGame from './games/shift/ShiftGame.js'; import BlockFighterGame from './games/blockfighter/BlockFighterGame.js'; +import MahjongMatchGame from './games/mahjongmatch/MahjongMatchGame.js'; const config = { type: Phaser.AUTO, @@ -145,6 +146,7 @@ const config = { PuddingMonstersGame, ShiftGame, BlockFighterGame, + MahjongMatchGame, ], }; diff --git a/public/src/scenes/GameRoomScene.js b/public/src/scenes/GameRoomScene.js index 461b897..4457a38 100644 --- a/public/src/scenes/GameRoomScene.js +++ b/public/src/scenes/GameRoomScene.js @@ -22,7 +22,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' }; + 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' }; if (slugDispatch[this.game.slug]) { this.scene.start(slugDispatch[this.game.slug], { game: this.game, diff --git a/public/src/scenes/PreloadScene.js b/public/src/scenes/PreloadScene.js index d6ce2c1..1fd3e8b 100644 --- a/public/src/scenes/PreloadScene.js +++ b/public/src/scenes/PreloadScene.js @@ -140,6 +140,19 @@ export default class PreloadScene extends Phaser.Scene { this.load.spritesheet('monopoly-pawns', '/assets/images/monopoly-pawns.png', { frameWidth: 80, frameHeight: 80 }); // Monopoly card art: frame 0 = Chance, frame 1 = Community Chest, at 200×300. this.load.spritesheet('monopoly-cards', '/assets/images/monopoly-cards.png', { frameWidth: 200, frameHeight: 300 }); + // Mahjong Match tile-label overlays (128×178 transparent PNGs). The White + // Dragon has no art — the scene draws its traditional empty frame. + const mahjongLabels = [ + ...Array.from({ length: 9 }, (_, i) => `bamboo${i + 1}`), + ...Array.from({ length: 9 }, (_, i) => `circle${i + 1}`), + ...Array.from({ length: 15 }, (_, i) => `pinyin${i + 1}`), + 'orchid', 'peony', 'chrysanthemum', 'lotus', + 'spring', 'summer', 'fall', 'winter', + ]; + for (const name of mahjongLabels) { + this.load.image(`mahjong-${name}`, `/assets/images/mahjong/${name}.png`); + } + this.load.audio('sfx-monopoly-purchase', '/assets/fx/monopoly-purchase.mp3'); this.load.audio('sfx-monopoly-expense', '/assets/fx/monopoly-expense.mp3'); this.load.audio('sfx-monopoly-pay', '/assets/fx/monopoly-pay.mp3'); diff --git a/server/games/registry.js b/server/games/registry.js index 06dca4e..33a8abd 100644 --- a/server/games/registry.js +++ b/server/games/registry.js @@ -81,3 +81,4 @@ registerGame({ slug: 'hexsweeper', name: 'Hexsweeper', category: registerGame({ slug: 'puddingmonsters', name: 'Jell-o Monsters', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 53 }); registerGame({ slug: 'shift', name: 'Shift', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 55 }); registerGame({ slug: 'blockfighter', name: 'Block Fighter', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, hasTutorial: true, iconFrame: 56 }); +registerGame({ slug: 'mahjongmatch', name: 'Mahjong Match', category: 'logic', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0, iconFrame: 57 }); diff --git a/server/scripts/verifyMahjongMatch.js b/server/scripts/verifyMahjongMatch.js new file mode 100644 index 0000000..c604889 --- /dev/null +++ b/server/scripts/verifyMahjongMatch.js @@ -0,0 +1,115 @@ +// Headless verification for Mahjong Match. +// node server/scripts/verifyMahjongMatch.js +// Exits non-zero on any failure. +// +// 1. Face set: 144 tiles, 72 pairs, label assets exist on disk. +// 2. Layouts: expected tile counts, no overlaps, every raised tile supported. +// 3. Deals: every generated board is clearable by replaying free matching +// pairs greedily with reshuffles (and spot-checked exhaustively). +// 4. Random self-play: full games with random move choice + shuffle-on-stuck. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + FACES, LAYOUTS, LAYOUT_ORDER, buildPairPool, validateLayout, + newGame, isFree, canMatch, removePair, findMoves, reshuffleRemaining, +} from '../../public/src/games/mahjongmatch/MahjongLogic.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const IMG_DIR = path.join(__dirname, '../../public/assets/images/mahjong'); + +let failures = 0; +function check(ok, msg) { + if (!ok) { failures++; console.error(` ✗ ${msg}`); } + return ok; +} + +// ── 1. Face set ───────────────────────────────────────────────────────────── +console.log('Face set:'); +const totalTiles = FACES.reduce((s, f) => s + f.copies, 0); +check(totalTiles === 144, `tile count ${totalTiles}, expected 144`); +check(FACES.length === 42, `face count ${FACES.length}, expected 42`); +const pool = buildPairPool(); +check(pool.length === 72, `pair pool ${pool.length}, expected 72`); +check(pool.every(([a, b]) => a.group === b.group), 'pair pool contains a mismatched pair'); +for (const f of FACES) { + if (!f.label) continue; // white dragon is drawn procedurally + const file = path.join(IMG_DIR, `${f.label.replace(/^mahjong-/, '')}.png`); + check(fs.existsSync(file), `missing label asset for ${f.id}: ${file}`); +} +console.log(' ok'); + +// ── 2. Layouts ────────────────────────────────────────────────────────────── +console.log('Layouts:'); +const EXPECTED_COUNTS = { garden: 72, crossroads: 90, pyramid: 106, butterfly: 140, fortress: 144, turtle: 144 }; +for (const key of LAYOUT_ORDER) { + const layout = LAYOUTS[key]; + const n = layout.positions.length; + check(n === EXPECTED_COUNTS[key], `${key}: ${n} tiles, expected ${EXPECTED_COUNTS[key]}`); + check(n <= 144, `${key}: more tiles than the set provides`); + const errors = validateLayout(layout.positions); + for (const e of errors) check(false, `${key}: ${e}`); + console.log(` ${layout.name.padEnd(11)} ${n} tiles${errors.length ? '' : ' ok'}`); +} + +// ── 3 & 4. Deals + random self-play ───────────────────────────────────────── +// Play with uniformly random move choice; on stuck, reshuffle (cap 30). +// Guaranteed-solvable deals can still strand under random play, but a healthy +// engine should clear the vast majority and never crash or stall. +function playRandomGame(layoutKey) { + const g = newGame(layoutKey); + let shuffles = 0; + let guard = 10000; + while (g.state === 'playing' && guard-- > 0) { + const moves = findMoves(g); + if (moves.length === 0) { + if (shuffles >= 30) return { g, cleared: false, shuffles }; + shuffles++; + const ok = reshuffleRemaining(g); + if (!ok && findMoves(g).length === 0) return { g, cleared: false, shuffles, dead: true }; + continue; + } + const [a, b] = moves[Math.floor(Math.random() * moves.length)]; + if (!check(isFree(g, a) && isFree(g, b) && canMatch(g, a, b), `${layoutKey}: findMoves returned illegal move`)) return { g, cleared: false, shuffles }; + if (!check(removePair(g, a, b), `${layoutKey}: removePair rejected a legal move`)) return { g, cleared: false, shuffles }; + } + check(guard > 0, `${layoutKey}: game did not terminate`); + return { g, cleared: g.state === 'won', shuffles }; +} + +console.log('Self-play (60 games per layout, random moves + shuffle on stuck):'); +for (const key of LAYOUT_ORDER) { + let cleared = 0, totalShuffles = 0, dead = 0; + for (let i = 0; i < 60; i++) { + const r = playRandomGame(key); + if (r.cleared) cleared++; + if (r.dead) dead++; + totalShuffles += r.shuffles; + if (r.g.state === 'won') { + check(r.g.remaining === 0, `${key}: won with ${r.g.remaining} tiles remaining`); + check(r.g.alive.every((a) => !a), `${key}: won with alive tiles`); + } + } + check(cleared >= 55, `${key}: only ${cleared}/60 games cleared — generator or engine suspect`); + console.log(` ${LAYOUTS[key].name.padEnd(11)} cleared ${cleared}/60, avg shuffles ${(totalShuffles / 60).toFixed(2)}, dead-ends ${dead}`); +} + +// Sanity: a fresh deal must always open with at least one available move. +console.log('Opening-move sanity (200 deals per layout):'); +for (const key of LAYOUT_ORDER) { + let minMoves = Infinity; + for (let i = 0; i < 200; i++) { + const g = newGame(key); + const m = findMoves(g).length; + if (m < minMoves) minMoves = m; + } + check(minMoves >= 1, `${key}: produced a deal with no opening move`); + console.log(` ${LAYOUTS[key].name.padEnd(11)} min opening moves ${minMoves}`); +} + +if (failures) { + console.error(`\nFAILED: ${failures} check(s).`); + process.exit(1); +} +console.log('\nAll Mahjong Match checks passed.');