808 lines
31 KiB
JavaScript
808 lines
31 KiB
JavaScript
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));
|
||
}
|
||
}
|