fertig-classic-games/src/games/triominoes/TriominoesGame.js

808 lines
31 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 { auth } from '../../services/auth.js';
import { api } from '../../services/api.js';
import { createOpponentPortrait, createPlayerPortrait } from '../../ui/Portrait.js';
import { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
createInitialState, getLegalMoves, playTile, drawTile, passTurn,
canDraw, getWinners, MAX_DRAWS,
} from './TriominoesLogic.js';
import { chooseMove, nextThinkDelay } from './TriominoesAI.js';
import {
cellVertices, cellCentroid, vertPixel, isUp, HALF_W,
PLAYER_COLORS, PLAYER_COLOR_HEX,
} from './TriominoesData.js';
// ─── Layout ──────────────────────────────────────────────────────────────
const CX = GAME_WIDTH / 2;
const VIEW_L = 330, VIEW_R = 1590, VIEW_T = 178, VIEW_B = 858;
const VIEW_CX = (VIEW_L + VIEW_R) / 2;
const VIEW_CY = (VIEW_T + VIEW_B) / 2;
const VIEW_W = VIEW_R - VIEW_L;
const VIEW_H = VIEW_B - VIEW_T;
const COL_X = 96; // left portrait column
const LABEL_X = 150;
const POOL_X = 1758, POOL_Y = 322;
const HAND_Y = 972;
const PAN_STEP = 240;
const SAFE_MARGIN = 130; // keep newest tile this far inside the viewport
const HAND_BASE = 120; // hand-tile triangle base width
const HAND_H = Math.round(HAND_BASE * 0.8660254);
// While hovering a legal cell the dragged tile scales down to the board cell's
// size so the preview matches the real footprint.
const PREVIEW_SCALE = (HALF_W * 2) / HAND_BASE;
const DEPTH = {
bg: -1, frame: 0, board: 2, arrows: 30,
col: 20, pool: 20, ui: 25, hand: 22, drag: 60, toast: 70, modal: 80,
};
export default class TriominoesGame extends Phaser.Scene {
constructor() { super('TriominoesGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.inputLocked = true;
this.gameOverShown = false;
this.portraitCtrls = [];
this.cellObjs = []; // board-tile containers (rebuilt each refresh)
this.handObjs = []; // hand-tile containers
this.labelTexts = [];
this._dragLegal = null;
this._poolActive = false;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
// Diagonal tri-gradient backdrop — deep teal → warm amber → slate blue.
const bg = this.add.graphics().setDepth(DEPTH.bg);
const C1 = Phaser.Display.Color.ValueToColor(0x0c2d3a); // deep teal
const C2 = Phaser.Display.Color.ValueToColor(0x3a2d1a); // warm amber
const C3 = Phaser.Display.Color.ValueToColor(0x1a2040); // slate blue
for (let i = 0; i < GAME_HEIGHT; i += 2) {
const yNorm = i / GAME_HEIGHT;
// Blend three colors along a diagonal: teal (top-left) → amber (center) → blue (bottom-right)
let r, g, b;
if (yNorm < 0.5) {
const t = yNorm * 2;
const c = Phaser.Display.Color.Interpolate.ColorWithColor(C1, C2, 100, Math.floor(t * 100));
r = c.r; g = c.g; b = c.b;
} else {
const t = (yNorm - 0.5) * 2;
const c = Phaser.Display.Color.Interpolate.ColorWithColor(C2, C3, 100, Math.floor(t * 100));
r = c.r; g = c.g; b = c.b;
}
bg.fillStyle(Phaser.Display.Color.GetColor(r, g, b), 1);
bg.fillRect(0, i, GAME_WIDTH, 2);
}
if (this.playfield?.key && this.textures.exists(this.playfield.key)) {
this.add.image(CX, GAME_HEIGHT / 2, this.playfield.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.bg);
}
const skills = [0, ...this.opponents.map((o) => Math.max(1, Math.min(5, o?.skill ?? 3)))];
this.skillBySeat = skills;
const playerNames = [
{ name: auth.user?.username ?? 'You', isAI: false },
...this.opponents.map((o) => ({ name: o.name ?? o.id ?? 'Bot', isAI: true, avatar: o })),
];
this.gs = createInitialState({ playerNames });
this.buildViewport();
this.buildHeader();
this.buildLeftColumn();
this.buildPool();
this.buildArrows();
this.setupDrag();
new Button(this, 92, GAME_HEIGHT - 40, 'Leave', () => this.scene.start('GameMenu'), {
variant: 'ghost', width: 150, fontSize: 20,
}).setDepth(DEPTH.ui);
new Button(this, GAME_WIDTH - 120, GAME_HEIGHT - 40, 'Re-center', () => this.recenterBoard(), {
variant: 'ghost', width: 180, fontSize: 20,
}).setDepth(DEPTH.ui);
// Centre the board origin in the viewport to start.
const c0 = cellCentroid(0, 0);
this.boardLayer.setPosition(VIEW_CX - c0.x, VIEW_CY - c0.y);
this.refresh();
this.time.delayedCall(500, () => this.nextTurn());
}
// ─── Static structure ──────────────────────────────────────────────────
buildViewport() {
// Felt panel + frame for the play window.
this.add.rectangle(VIEW_CX, VIEW_CY, VIEW_W, VIEW_H, 0x123022, 1)
.setStrokeStyle(3, COLORS.accent, 0.8).setDepth(DEPTH.frame);
this.boardLayer = this.add.container(0, 0).setDepth(DEPTH.board);
const maskShape = this.make.graphics();
maskShape.fillStyle(0xffffff);
maskShape.fillRect(VIEW_L, VIEW_T, VIEW_W, VIEW_H);
this.boardLayer.setMask(maskShape.createGeometryMask());
this.ghostGfx = this.add.graphics();
this.boardLayer.add(this.ghostGfx);
}
buildHeader() {
this.add.text(CX, 40, 'Tri-Ominoes', {
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(14, 6);
this.statusText = this.add.text(CX, 110, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(12, 5);
}
buildLeftColumn() {
const n = this.gs.players.length;
const top = 230, gap = Math.min(168, (820 - top) / Math.max(1, n - 1) || 168);
for (let i = 0; i < n; i++) {
const y = top + i * gap;
const ring = this.add.graphics().setDepth(DEPTH.col);
let controller;
if (i === 0) {
controller = createPlayerPortrait(this, COL_X, y, 44, DEPTH.col, 'TriominoesGame');
} else {
const opp = this.opponents[i - 1] ?? { id: 'bot', spriteIndex: 0 };
controller = createOpponentPortrait(this, opp, COL_X, y, 44, DEPTH.col);
}
this.portraitCtrls.push({ ring, controller, x: COL_X, y });
const label = this.add.text(LABEL_X, y, '', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.textHex,
align: 'left', lineSpacing: 3,
}).setOrigin(0, 0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(8, 5);
this.labelTexts.push(label);
}
}
buildPool() {
this.add.text(POOL_X, POOL_Y - 96, 'POOL', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(10, 4);
// A little scatter of face-down triangles.
const offs = [[0, 0, 8], [-14, 10, -12], [12, -8, 16], [-8, -12, 24], [10, 12, -18]];
this._poolTiles = offs.map(([dx, dy, a]) => {
const c = this.add.container(POOL_X + dx, POOL_Y + dy).setAngle(a).setDepth(DEPTH.pool);
const g = this.add.graphics();
this.paintFaceDownTri(g, HAND_BASE * 0.62);
c.add(g);
return c;
});
this.poolText = this.add.text(POOL_X, POOL_Y + 84, '', {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui)
.setBackgroundColor('rgba(0,0,0,0.55)').setPadding(10, 4);
this._poolZone = this.add.zone(POOL_X, POOL_Y, 200, 200).setOrigin(0.5).setDepth(DEPTH.pool);
this._poolZone.on('pointerup', () => this.onPoolClick());
}
buildArrows() {
const mk = (x, y, dir) => this.makeArrow(x, y, dir);
this._arrows = [
mk(VIEW_L + 34, VIEW_CY, 'left'),
mk(VIEW_R - 34, VIEW_CY, 'right'),
mk(VIEW_CX, VIEW_T + 34, 'up'),
mk(VIEW_CX, VIEW_B - 34, 'down'),
];
}
makeArrow(x, y, dir) {
const r = 26;
const c = this.add.container(x, y).setDepth(DEPTH.arrows);
const g = this.add.graphics();
g.fillStyle(0x000000, 0.5); g.fillCircle(0, 0, r);
g.lineStyle(2, COLORS.accent, 0.9); g.strokeCircle(0, 0, r);
g.fillStyle(COLORS.accent, 1);
const s = 11;
const tri = {
left: [[-s, 0], [s, -s], [s, s]],
right: [[s, 0], [-s, -s], [-s, s]],
up: [[0, -s], [-s, s], [s, s]],
down: [[0, s], [-s, -s], [s, -s]],
}[dir];
g.fillTriangle(tri[0][0], tri[0][1], tri[1][0], tri[1][1], tri[2][0], tri[2][1]);
c.add(g);
c.setSize(r * 2, r * 2).setInteractive({ useHandCursor: true });
c.on('pointerover', () => c.setScale(1.12));
c.on('pointerout', () => c.setScale(1));
c.on('pointerup', () => this.pan(dir));
return c;
}
pan(dir) {
const d = { left: [PAN_STEP, 0], right: [-PAN_STEP, 0], up: [0, PAN_STEP], down: [0, -PAN_STEP] }[dir];
this.tweens.add({
targets: this.boardLayer,
x: this.boardLayer.x + d[0],
y: this.boardLayer.y + d[1],
duration: 220, ease: 'Cubic.easeOut',
});
}
recenterBoard() {
// Centre on the bounding box of all placed tiles (or the origin if empty).
const keys = Object.keys(this.gs.board.cells);
let cx, cy;
if (keys.length === 0) {
const c0 = cellCentroid(0, 0); cx = c0.x; cy = c0.y;
} else {
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const k of keys) {
const { r, c } = this.gs.board.cells[k];
const ct = cellCentroid(r, c);
minX = Math.min(minX, ct.x); maxX = Math.max(maxX, ct.x);
minY = Math.min(minY, ct.y); maxY = Math.max(maxY, ct.y);
}
cx = (minX + maxX) / 2; cy = (minY + maxY) / 2;
}
this.tweens.add({
targets: this.boardLayer,
x: VIEW_CX - cx, y: VIEW_CY - cy,
duration: 320, ease: 'Cubic.easeOut',
});
}
// Nudge the board so a given cell sits comfortably inside the viewport.
ensureCellVisible(r, c) {
const ct = cellCentroid(r, c);
const sx = ct.x + this.boardLayer.x;
const sy = ct.y + this.boardLayer.y;
let dx = 0, dy = 0;
if (sx < VIEW_L + SAFE_MARGIN) dx = (VIEW_L + SAFE_MARGIN) - sx;
else if (sx > VIEW_R - SAFE_MARGIN) dx = (VIEW_R - SAFE_MARGIN) - sx;
if (sy < VIEW_T + SAFE_MARGIN) dy = (VIEW_T + SAFE_MARGIN) - sy;
else if (sy > VIEW_B - SAFE_MARGIN) dy = (VIEW_B - SAFE_MARGIN) - sy;
if (dx || dy) {
this.tweens.add({
targets: this.boardLayer,
x: this.boardLayer.x + dx, y: this.boardLayer.y + dy,
duration: 300, ease: 'Cubic.easeOut',
});
}
}
// ─── Render ────────────────────────────────────────────────────────────
refresh() {
this.rebuildBoard();
this.rebuildHand();
this.updatePortraits();
this.updateLabels();
this.updateStatus();
this.updatePool();
}
rebuildBoard() {
for (const o of this.cellObjs) o.destroy();
this.cellObjs = [];
for (const key of Object.keys(this.gs.board.cells)) {
const cell = this.gs.board.cells[key];
const ct = cellCentroid(cell.r, cell.c);
const pts = cellVertices(cell.r, cell.c).map(([vr, vx]) => {
const p = vertPixel(vr, vx); return [p.x - ct.x, p.y - ct.y];
});
const container = this.add.container(ct.x, ct.y);
const border = PLAYER_COLORS[cell.owner % PLAYER_COLORS.length];
this.paintTriangle(container, pts, cell.vals, {
fill: COLORS.text, border, lineW: 3, fontSize: 26,
});
this.boardLayer.add(container);
this.cellObjs.push(container);
}
// keep ghosts above tiles
this.boardLayer.bringToTop(this.ghostGfx);
}
// Draw a triangle (local pts) with the three corner numbers into a container.
paintTriangle(container, pts, vals, opts) {
const { fill, border, lineW = 3, fontSize = 26 } = opts;
const g = this.add.graphics();
g.fillStyle(fill, 1);
g.fillTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]);
g.lineStyle(lineW, border, 1);
g.strokeTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]);
container.add(g);
for (let i = 0; i < 3; i++) {
const tx = pts[i][0] * 0.62, ty = pts[i][1] * 0.62;
const t = this.add.text(tx, ty, String(vals[i]), {
fontFamily: 'Righteous', fontSize: `${fontSize}px`, color: COLORS.textDarkHex,
}).setOrigin(0.5);
container.add(t);
}
}
// Local corners of an upward-pointing hand triangle (clockwise top,BR,BL).
handPts() {
return [[0, -HAND_H * 2 / 3], [HAND_BASE / 2, HAND_H / 3], [-HAND_BASE / 2, HAND_H / 3]];
}
rebuildHand() {
for (const o of this.handObjs) o.destroy();
this.handObjs = [];
const hand = this.gs.players[0].hand;
const humanTurn = this.gs.current === 0 && this.gs.phase === 'playing';
const legalTiles = humanTurn
? new Set(getLegalMoves(this.gs, 0).map((m) => m.tileIndex))
: new Set();
const pitch = HAND_BASE + 22;
const startX = CX - ((hand.length - 1) * pitch) / 2;
const pts = this.handPts();
const anyPlayable = legalTiles.size > 0;
hand.forEach((tile, idx) => {
const x = startX + idx * pitch;
const playable = humanTurn && legalTiles.has(idx);
const container = this.add.container(x, HAND_Y).setDepth(DEPTH.hand);
const border = playable ? COLORS.accent : COLORS.muted;
this.paintTriangle(container, pts, tile.v, { fill: COLORS.text, border, lineW: 3, fontSize: 30 });
container.setAlpha(humanTurn ? (anyPlayable ? 1 : 0.42) : 0.7);
container._tileIndex = idx;
container._homeX = x;
container._homeY = HAND_Y;
container._playable = playable;
container.setSize(HAND_BASE, HAND_H);
// setSize gives the container displayOrigin (w/2, h/2), which Phaser ADDS
// to the local pointer before the hit test — so the hit area must be
// expressed in that origin-shifted space, i.e. our centred triangle moved
// by (+w/2, +h/2). Skipping this leaves the hitbox up-and-left of the tile.
const ox = HAND_BASE / 2, oy = HAND_H / 2;
const hit = new Phaser.Geom.Triangle(
pts[0][0] + ox, pts[0][1] + oy,
pts[1][0] + ox, pts[1][1] + oy,
pts[2][0] + ox, pts[2][1] + oy,
);
container.setInteractive(hit, Phaser.Geom.Triangle.Contains, { useHandCursor: playable });
if (playable) this.input.setDraggable(container);
this.handObjs.push(container);
});
}
updatePortraits() {
for (let i = 0; i < this.portraitCtrls.length; i++) {
const { ring, x, y } = this.portraitCtrls[i];
ring.clear();
if (i === this.gs.current && this.gs.phase === 'playing') {
ring.lineStyle(4, COLORS.gold, 1);
ring.strokeCircle(x, y, 50);
}
}
}
updateLabels() {
for (let i = 0; i < this.gs.players.length; i++) {
const p = this.gs.players[i];
this.labelTexts[i].setText(`${p.name}\n${p.hand.length} tiles · ${p.score} pts`);
this.labelTexts[i].setColor(i === 0 ? PLAYER_COLOR_HEX[0] : COLORS.textHex);
}
}
updateStatus() {
if (this.gs.phase === 'gameover') { this.statusText.setText('Game over'); return; }
const cur = this.gs.players[this.gs.current];
this.statusText.setText(this.gs.current === 0 ? 'Your turn — drag a tile onto the board' : `${cur.name}'s turn`);
}
updatePool() {
const count = this.gs.pool.length;
const shown = Math.min(count, this._poolTiles.length);
this._poolTiles.forEach((t, i) => t.setVisible(i < shown));
this.poolText.setText(count > 0 ? `${count} left` : 'Empty');
}
paintFaceDownTri(g, size) {
const h = Math.round(size * 0.8660254);
const pts = [[0, -h * 2 / 3], [size / 2, h / 3], [-size / 2, h / 3]];
g.fillStyle(COLORS.panel, 1);
g.fillTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]);
g.lineStyle(2, COLORS.accent, 0.9);
g.strokeTriangle(pts[0][0], pts[0][1], pts[1][0], pts[1][1], pts[2][0], pts[2][1]);
g.fillStyle(COLORS.accent, 0.35);
g.fillCircle(0, 0, Math.max(3, size * 0.06));
}
// ─── Drag & drop ───────────────────────────────────────────────────────
setupDrag() {
this.input.on('dragstart', (_p, obj) => {
if (this.inputLocked || !obj._playable) return;
obj._dragging = true;
obj.setDepth(DEPTH.drag).setAngle(0).setScale(1);
this._previewKey = 'none';
this._dragLegal = getLegalMoves(this.gs, 0)
.filter((m) => m.tileIndex === obj._tileIndex)
.map((m) => { const ct = cellCentroid(m.r, m.c); return { ...m, cx: ct.x, cy: ct.y }; });
this.drawGhosts();
});
this.input.on('drag', (_p, obj, dx, dy) => {
if (!obj._dragging) return;
obj.setPosition(dx, dy);
this._hoverMove = this.dropTargetAt(dx, dy);
this.updateDragPreview(obj, this._hoverMove);
this.drawGhosts();
});
this.input.on('dragend', (_p, obj) => {
if (!obj._dragging) return;
obj._dragging = false;
const target = this.dropTargetAt(obj.x, obj.y);
this.ghostGfx.clear();
this._dragLegal = null;
this._hoverMove = null;
this._previewKey = 'none';
this.tweens.killTweensOf(obj);
if (target) {
// Already rotated/scaled into the fit by the hover preview — drop it.
obj.setVisible(false);
const move = { tileIndex: target.tileIndex, r: target.r, c: target.c, rot: target.rot };
this.time.delayedCall(0, () => this.applyMove(move));
} else {
obj.setDepth(DEPTH.hand);
const back = Phaser.Math.Angle.ShortestBetween(obj.angle, 0);
this.tweens.add({
targets: obj, x: obj._homeX, y: obj._homeY,
angle: obj.angle + back, scaleX: 1, scaleY: 1,
duration: 160, ease: 'Back.easeOut',
});
}
});
}
// The on-screen rotation (deg, clockwise) that makes the upward hand tile —
// drawn v0=top, v1=BR, v2=BL — land exactly as it would once placed. Derived
// from the cell's corner directions: an up cell keeps the tile upward (0/120/
// 240 by rotation), a down cell flips it (300/180/60).
targetAngleFor(move) {
return isUp(move.r, move.c)
? [0, 240, 120][move.rot]
: [300, 180, 60][move.rot];
}
// Animate the dragged tile to preview the fit: rotate to the placed
// orientation and shrink to the board cell's size while hovering a legal cell;
// ease back to the upright hand size when over open space.
updateDragPreview(obj, target) {
const key = target ? `${target.r},${target.c},${target.rot}` : 'none';
if (key === this._previewKey) return;
this._previewKey = key;
this.tweens.killTweensOf(obj);
const angle = target ? this.targetAngleFor(target) : 0;
const scale = target ? PREVIEW_SCALE : 1;
const delta = Phaser.Math.Angle.ShortestBetween(obj.angle, angle);
this.tweens.add({
targets: obj,
angle: obj.angle + delta,
scaleX: scale, scaleY: scale,
duration: 170, ease: 'Cubic.easeOut',
});
}
// Which legal placement (if any) the pointer is over. Requires the pointer to
// be inside the viewport and near a legal cell centroid.
dropTargetAt(px, py) {
if (!this._dragLegal || px < VIEW_L || px > VIEW_R || py < VIEW_T || py > VIEW_B) return null;
const bx = px - this.boardLayer.x, by = py - this.boardLayer.y;
let best = null, bestD = 70 * 70; // snap radius²
for (const m of this._dragLegal) {
const d = (m.cx - bx) ** 2 + (m.cy - by) ** 2;
if (d < bestD) { bestD = d; best = m; }
}
return best;
}
drawGhosts() {
const g = this.ghostGfx;
g.clear();
if (!this._dragLegal) return;
for (const m of this._dragLegal) {
const pts = cellVertices(m.r, m.c).map(([vr, vx]) => vertPixel(vr, vx));
const hot = this._hoverMove && this._hoverMove.r === m.r && this._hoverMove.c === m.c;
g.fillStyle(COLORS.gold, hot ? 0.42 : 0.16);
g.fillTriangle(pts[0].x, pts[0].y, pts[1].x, pts[1].y, pts[2].x, pts[2].y);
g.lineStyle(hot ? 4 : 2, COLORS.gold, hot ? 1 : 0.6);
g.strokeTriangle(pts[0].x, pts[0].y, pts[1].x, pts[1].y, pts[2].x, pts[2].y);
}
}
// ─── Draw animation ─────────────────────────────────────────────────────
// Animate a face-down triangle from the pool to the player's portrait.
// Returns a promise that resolves when the animation completes.
animateDrawTile(seat) {
return new Promise((resolve) => {
const portrait = this.portraitCtrls[seat];
if (!portrait) { resolve(); return; }
const px = portrait.x;
const py = portrait.y;
const poolX = POOL_X;
const poolY = POOL_Y;
const size = HAND_BASE * 0.45;
const h = Math.round(size * 0.8660254);
const triPts = [[0, -h * 2 / 3], [size / 2, h / 3], [-size / 2, h / 3]];
const container = this.add.container(poolX, poolY).setDepth(DEPTH.drag);
const gfx = this.add.graphics();
// Face-down: dark fill, subtle border, small center dot.
gfx.fillStyle(COLORS.panel, 1);
gfx.fillTriangle(triPts[0][0], triPts[0][1], triPts[1][0], triPts[1][1], triPts[2][0], triPts[2][1]);
gfx.lineStyle(2, COLORS.accent, 0.9);
gfx.strokeTriangle(triPts[0][0], triPts[0][1], triPts[1][0], triPts[1][1], triPts[2][0], triPts[2][1]);
gfx.fillStyle(COLORS.accent, 0.35);
gfx.fillCircle(0, 0, Math.max(3, size * 0.06));
container.add(gfx);
container.setScale(1).setAngle(0);
this.tweens.add({
targets: container,
x: px,
y: py,
duration: 1200,
ease: 'Cubic.easeInOut',
onComplete: () => {
container.destroy();
resolve();
},
});
});
}
// ─── AI tile animation ──────────────────────────────────────────────────
// Create a small triangle at the opponent's portrait and tween it to the
// board cell over 1.2 s, rotating and scaling as it goes. Returns a promise
// that resolves when the animation completes.
animateAITile(move, seat) {
return new Promise((resolve) => {
const tile = this.gs.players[seat].hand[move.tileIndex];
if (!tile) { resolve(); return; }
const portrait = this.portraitCtrls[seat];
if (!portrait) { resolve(); return; }
const px = portrait.x;
const py = portrait.y;
const ct = cellCentroid(move.r, move.c);
const finalAngle = this.targetAngleFor(move);
const startSize = HAND_BASE * 0.33;
const endSize = HALF_W * 2;
// Build the triangle tile as a container (graphics + text children).
const h = Math.round(startSize * 0.8660254);
const triPts = [[0, -h * 2 / 3], [startSize / 2, h / 3], [-startSize / 2, h / 3]];
const container = this.add.container(px, py).setDepth(DEPTH.drag);
const gfx = this.add.graphics();
gfx.fillStyle(COLORS.text, 1);
gfx.fillTriangle(triPts[0][0], triPts[0][1], triPts[1][0], triPts[1][1], triPts[2][0], triPts[2][1]);
gfx.lineStyle(2, COLORS.muted, 1);
gfx.strokeTriangle(triPts[0][0], triPts[0][1], triPts[1][0], triPts[1][1], triPts[2][0], triPts[2][1]);
container.add(gfx);
// Corner numbers.
for (let i = 0; i < 3; i++) {
const tx = triPts[i][0] * 0.62, ty = triPts[i][1] * 0.62;
const t = this.add.text(tx, ty, String(tile.v[(i + move.rot) % 3]), {
fontFamily: 'Righteous', fontSize: '14px', color: COLORS.textDarkHex,
}).setOrigin(0.5);
container.add(t);
}
container.setScale(1).setAngle(0);
// Tween from portrait → board cell.
this.tweens.add({
targets: container,
x: ct.x + this.boardLayer.x,
y: ct.y + this.boardLayer.y,
scaleX: endSize / startSize,
scaleY: endSize / startSize,
angle: finalAngle,
duration: 1200,
ease: 'Cubic.easeInOut',
onComplete: () => {
container.destroy();
resolve();
},
});
});
}
// ─── Turn flow ─────────────────────────────────────────────────────────
nextTurn() {
this.showPoolClickable(false);
if (this.gs.phase === 'gameover') { this.showGameOverModal(); return; }
this.refresh();
if (this.gs.players[this.gs.current].isAI) this.runAITurn();
else this.beginHumanTurn();
}
beginHumanTurn() {
this.inputLocked = false;
this.refresh();
const moves = getLegalMoves(this.gs, 0);
if (moves.length > 0) return;
// No play — must draw (if possible) or pass.
this.inputLocked = true;
if (canDraw(this.gs) && this.gs.players[0].draws < MAX_DRAWS) {
this.statusText.setText('No legal play — click the POOL to draw (5)');
this.showPoolClickable(true);
} else {
this.statusText.setText('No play — passing');
this.time.delayedCall(800, () => { this.gs = passTurn(this.gs); this.nextTurn(); });
}
}
applyMove(move) {
this.inputLocked = true;
this.gs = playTile(this.gs, move);
playSound(this, SFX.PIECE_CLICK);
this.refresh();
this.flashCell(move.r, move.c);
this.ensureCellVisible(move.r, move.c);
this.time.delayedCall(360, () => this.nextTurn());
}
onPoolClick() {
if (!this._poolActive) return;
this.showPoolClickable(false);
playSound(this, SFX.CARD_DEAL);
// Animate tile from pool → player's portrait.
this.animateDrawTile(0).then(() => {
this.gs = drawTile(this.gs);
this.refresh();
// Re-evaluate: a drawn tile may now be playable.
this.time.delayedCall(260, () => this.beginHumanTurn());
});
}
showPoolClickable(on) {
this._poolActive = on;
if (!this._poolZone) return;
if (on) {
this._poolZone.setInteractive({ useHandCursor: true });
for (const c of this._poolTiles) {
if (c.visible) this.tweens.add({ targets: c, scale: 1.12, duration: 340, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' });
}
} else {
this._poolZone.disableInteractive();
for (const c of this._poolTiles) { this.tweens.killTweensOf(c); c.setScale(1); }
}
}
async runAITurn() {
this.inputLocked = true;
const seat = this.gs.current;
const skill = this.skillBySeat[seat] ?? 3;
let guard = 0;
while (this.gs.phase === 'playing' && this.gs.current === seat && guard++ < 80) {
let moves = getLegalMoves(this.gs, seat);
if (moves.length === 0) {
if (canDraw(this.gs) && this.gs.players[seat].draws < MAX_DRAWS) {
await this.delay(360);
playSound(this, SFX.CARD_DEAL);
await this.animateDrawTile(seat);
this.gs = drawTile(this.gs);
this.refresh();
continue;
}
await this.delay(420);
this.gs = passTurn(this.gs);
break;
}
await this.delay(nextThinkDelay(skill));
const move = chooseMove(this.gs, seat, skill);
// Animate the tile from the opponent's portrait to the board.
await this.animateAITile(move, seat);
this.gs = playTile(this.gs, move);
playSound(this, SFX.PIECE_CLICK);
this.refresh();
this.flashCell(move.r, move.c);
this.ensureCellVisible(move.r, move.c);
break;
}
await this.delay(380);
this.nextTurn();
}
flashCell(r, c) {
const pts = cellVertices(r, c).map(([vr, vx]) => vertPixel(vr, vx));
const fx = this.add.graphics();
fx.lineStyle(4, COLORS.gold, 1);
fx.strokeTriangle(pts[0].x, pts[0].y, pts[1].x, pts[1].y, pts[2].x, pts[2].y);
this.boardLayer.add(fx);
this.tweens.add({ targets: fx, alpha: { from: 1, to: 0 }, duration: 520, onComplete: () => fx.destroy() });
}
// ─── Game over ─────────────────────────────────────────────────────────
showGameOverModal() {
if (this.gameOverShown) return;
this.gameOverShown = true;
this.refresh();
this.postHistory().catch(() => {});
const winners = new Set(getWinners(this.gs));
const humanWon = winners.has(0);
if (winners.size === 1 && humanWon) playSound(this, SFX.CASINO_WIN);
else if (!humanWon) playSound(this, SFX.CASINO_LOSE);
for (let i = 1; i < this.gs.players.length; i++) {
this.portraitCtrls[i]?.controller?.playEmotion?.(winners.has(i) ? 'happy' : 'upset');
}
this.add.rectangle(CX, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.68)
.setInteractive().setDepth(DEPTH.modal);
const n = this.gs.players.length;
const panelW = 720, panelH = 220 + n * 52;
this.add.rectangle(CX, GAME_HEIGHT / 2, panelW, panelH, COLORS.panel, 1)
.setStrokeStyle(2, COLORS.accent).setDepth(DEPTH.modal);
const top = GAME_HEIGHT / 2 - panelH / 2;
const heading = winners.size === 1 && humanWon ? 'You win!'
: humanWon ? 'Tied for the win' : `${this.gs.players[[...winners][0]].name} wins`;
this.add.text(CX, top + 50, heading, {
fontFamily: 'Righteous', fontSize: '44px', color: COLORS.goldHex,
}).setOrigin(0.5).setDepth(DEPTH.modal);
this.add.text(CX, top + 92, 'Final scores (highest wins)', {
fontFamily: '"Julius Sans One"', fontSize: '17px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.modal);
const order = this.gs.players.map((p, i) => ({ i, p })).sort((a, b) => b.p.score - a.p.score);
let rowY = top + 134;
for (const { i, p } of order) {
const win = winners.has(i);
const color = win ? COLORS.goldHex : COLORS.textHex;
this.add.text(CX - panelW / 2 + 40, rowY, `${win ? '★ ' : ' '}${p.name}`, {
fontFamily: 'Righteous', fontSize: '24px', color,
}).setOrigin(0, 0.5).setDepth(DEPTH.modal);
this.add.text(CX + panelW / 2 - 40, rowY, String(p.score), {
fontFamily: 'Righteous', fontSize: '26px', color,
}).setOrigin(1, 0.5).setDepth(DEPTH.modal);
rowY += 48;
}
new Button(this, CX, GAME_HEIGHT / 2 + panelH / 2 - 46, 'Back to Menu',
() => this.scene.start('GameMenu'), { width: 280, fontSize: 24 }).setDepth(DEPTH.modal);
}
async postHistory() {
const totals = this.gs.players.map((p) => p.score);
const winners = new Set(getWinners(this.gs));
let result;
if (winners.has(0) && winners.size === 1) result = 'win';
else if (winners.has(0)) result = 'draw';
else result = 'loss';
await api.post('/history/single-player', {
slug: 'triominoes',
score: totals[0],
opponentScores: totals.slice(1),
result,
});
}
delay(ms) {
return new Promise((resolve) => this.time.delayedCall(ms, resolve));
}
}