fertig-classic-games/public/src/games/mahjongmatch/MahjongMatchGame.js

543 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}