543 lines
20 KiB
JavaScript
543 lines
20 KiB
JavaScript
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, 155, '', {
|
||
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();
|
||
}
|
||
}
|