feat: add Mahjong Match solitaire game with 6 layouts and guaranteed-solvable deals
- Add complete Mahjong Match game (MahjongLogic.js + MahjongMatchGame.js) with pure board model and Phaser UI - Implement 6 layouts: Garden, Crossroads, Pyramid, Butterfly, Fortress, and classic Turtle (up to 144 tiles) - Generate guaranteed-solvable deals by simulating reverse removal of free pairs - Add tile matching logic using half-tile coordinate system for proper layer overlap detection - Include 38 tile face images (bamboo, circle, pinyin characters, winds, dragons, flowers, seasons) - Add hint system, shuffle functionality, timer, best time tracking per layout - Register game in server registry and scene dispatch tables - Add headless verification script for face set validation and random self-play testing
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||