**feat: add Tri-Ominoes game with AI opponents**
Introduce Tri-Ominoes, a triangular grid tile-placement game for 2–4 players. The implementation includes a pure rules engine (`TriominoesLogic`), triangular grid geometry and data (`TriominoesData`), a Phaser scene (`TriominoesGame`), and a heuristic AI with 5 skill levels (`TriominoesAI`). Key features: - Corner-matching tile placement on an equilateral triangular grid - Scoring with pip sums, hexagon-closing bonuses, and going-out rewards - Draw-from-pool and pass mechanics with forced pass when blocked - Animated AI tile placement from opponent portraits - Board panning and re-centering for large play areas - Game-over modal with final scores and history posting Also updates the game registry, scene dispatch, opponent select skill controls, and adds two new character portraits (beth, blackwind).
This commit is contained in:
parent
40a0f3235a
commit
a231d821ca
Binary file not shown.
|
Before Width: | Height: | Size: 182 KiB After Width: | Height: | Size: 184 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
|
|
@ -0,0 +1,45 @@
|
|||
// Heuristic Tri-Ominoes AI. Stateless; consumed by TriominoesGame.
|
||||
//
|
||||
// chooseMove(state, idx, skill) -> a move from getLegalMoves(), or null.
|
||||
// The scene handles the draw/pass flow when no legal move exists. Skill 1..5
|
||||
// scales how reliably the AI takes the highest-scoring placement vs. a random
|
||||
// legal one, giving weaker bots a human-ish wobble.
|
||||
|
||||
import { getLegalMoves, HEXAGON_BONUS } from './TriominoesLogic.js';
|
||||
import { cellVertices, vertKey, tileValue } from './TriominoesData.js';
|
||||
|
||||
const SKILL_GREED = { 1: 0.15, 2: 0.4, 3: 0.65, 4: 0.85, 5: 1 };
|
||||
|
||||
export function nextThinkDelay(skill = 3) {
|
||||
const base = 520 - skill * 40;
|
||||
return base + Math.floor(Math.random() * 260);
|
||||
}
|
||||
|
||||
function scorePlacement(state, move, tile) {
|
||||
// Shed heavy tiles first (they hurt most if we're caught holding them) and
|
||||
// chase hexagon closes.
|
||||
let score = tileValue(tile);
|
||||
const verts = cellVertices(move.r, move.c);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [vr, vx] = verts[i];
|
||||
if (state.board.vertCount[vertKey(vr, vx)] === 5) score += HEXAGON_BONUS;
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
export function chooseMove(state, idx, skill = 3) {
|
||||
const moves = getLegalMoves(state, idx);
|
||||
if (moves.length === 0) return null;
|
||||
|
||||
const hand = state.players[idx].hand;
|
||||
const ranked = moves
|
||||
.map((m) => ({ m, s: scorePlacement(state, m, hand[m.tileIndex]) }))
|
||||
.sort((a, b) => b.s - a.s);
|
||||
|
||||
const greed = SKILL_GREED[Math.max(1, Math.min(5, skill | 0))] ?? 0.65;
|
||||
if (Math.random() < greed) return ranked[0].m;
|
||||
|
||||
// Off-greed: pick from the better half so weaker play still isn't terrible.
|
||||
const half = Math.max(1, Math.ceil(ranked.length / 2));
|
||||
return ranked[Math.floor(Math.random() * half)].m;
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
// Tri-Ominoes — static data and triangular-grid geometry. No Phaser, no state.
|
||||
//
|
||||
// The board is a triangular grid. Each cell is an equilateral triangle keyed by
|
||||
// (r, c). A cell "points up" when (r + c) is even and "points down" otherwise.
|
||||
// Up and down triangles interlock to tile the plane.
|
||||
//
|
||||
// Every cell owns three CORNER VERTICES, listed clockwise (screen coords, y
|
||||
// down). Vertices are shared between neighbouring cells, so a vertex's number is
|
||||
// shared by every tile that touches it — which is exactly the Tri-Ominoes
|
||||
// matching rule (touching corners must agree). We identify a vertex by lattice
|
||||
// coords (vr, vx): horizontal line index vr and half-column vx.
|
||||
//
|
||||
// up (r,c): top=(r, c+1) bottom-right=(r+1, c+2) bottom-left=(r+1, c)
|
||||
// down (r,c): top-left=(r,c) top-right=(r, c+2) bottom=(r+1, c+1)
|
||||
//
|
||||
// Both windings are clockwise, so the three placement rotations of a tile are
|
||||
// just cyclic shifts of its corner triple — reflections (which a physical tile
|
||||
// can't do) never sneak in.
|
||||
|
||||
export const VALUE_MAX = 5; // double-five set: corner pips 0..5
|
||||
|
||||
// All 56 distinct triangles (multisets of 3 values 0..5). The stored triple is
|
||||
// sorted ascending and treated as the tile's CLOCKWISE corner order.
|
||||
export function buildTileSet() {
|
||||
const tiles = [];
|
||||
let id = 0;
|
||||
for (let a = 0; a <= VALUE_MAX; a++) {
|
||||
for (let b = a; b <= VALUE_MAX; b++) {
|
||||
for (let c = b; c <= VALUE_MAX; c++) {
|
||||
tiles.push({ id: id++, v: [a, b, c] });
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiles; // 56 tiles
|
||||
}
|
||||
|
||||
export const tileValue = (t) => t.v[0] + t.v[1] + t.v[2];
|
||||
export const isTripleTile = (t) => t.v[0] === t.v[1] && t.v[1] === t.v[2];
|
||||
|
||||
// Standard hand sizes: 2 players draw 9, 3–4 draw 7.
|
||||
export function handSizeFor(n) {
|
||||
return n === 2 ? 9 : 7;
|
||||
}
|
||||
|
||||
// ── Geometry ─────────────────────────────────────────────────────────────────
|
||||
export function isUp(r, c) {
|
||||
return (((r + c) % 2) + 2) % 2 === 0;
|
||||
}
|
||||
|
||||
export const cellKey = (r, c) => `${r},${c}`;
|
||||
export const vertKey = (vr, vx) => `${vr},${vx}`;
|
||||
|
||||
// Three corner vertices in clockwise order. Order matters: a placed tile maps
|
||||
// corner i ← tileTriple[(i + rot) % 3].
|
||||
export function cellVertices(r, c) {
|
||||
if (isUp(r, c)) {
|
||||
return [
|
||||
[r, c + 1], // top
|
||||
[r + 1, c + 2], // bottom-right
|
||||
[r + 1, c], // bottom-left
|
||||
];
|
||||
}
|
||||
return [
|
||||
[r, c], // top-left
|
||||
[r, c + 2], // top-right
|
||||
[r + 1, c + 1], // bottom
|
||||
];
|
||||
}
|
||||
|
||||
// The three edge-adjacent cells. The slanted-edge neighbours sit in the same
|
||||
// row (c±1); the flat-edge neighbour is one row away depending on orientation.
|
||||
export function neighborCells(r, c) {
|
||||
return isUp(r, c)
|
||||
? [[r, c - 1], [r, c + 1], [r + 1, c]]
|
||||
: [[r, c - 1], [r, c + 1], [r - 1, c]];
|
||||
}
|
||||
|
||||
// ── Pixel mapping ─────────────────────────────────────────────────────────────
|
||||
// A vertex (vr, vx) maps to a board-space pixel. HALF_W is half the triangle
|
||||
// base; row height keeps the triangles equilateral.
|
||||
export const HALF_W = 46;
|
||||
export const ROW_H = Math.round(HALF_W * 2 * 0.8660254); // ≈ 80
|
||||
|
||||
export function vertPixel(vr, vx) {
|
||||
return { x: vx * HALF_W, y: vr * ROW_H };
|
||||
}
|
||||
|
||||
export function cellCentroid(r, c) {
|
||||
const vs = cellVertices(r, c);
|
||||
let x = 0, y = 0;
|
||||
for (const [vr, vx] of vs) { const p = vertPixel(vr, vx); x += p.x; y += p.y; }
|
||||
return { x: x / 3, y: y / 3 };
|
||||
}
|
||||
|
||||
// ── Players ─────────────────────────────────────────────────────────────────
|
||||
export const PLAYER_COLORS = [0xc8a84b, 0x4a90d9, 0x49a25a, 0xd0473a];
|
||||
export const PLAYER_COLOR_HEX = ['#c8a84b', '#4a90d9', '#49a25a', '#d0473a'];
|
||||
|
|
@ -0,0 +1,759 @@
|
|||
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();
|
||||
|
||||
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 ? (playable ? 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 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);
|
||||
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);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
// Tri-Ominoes — pure rules engine. No Phaser, no rendering, no timers. Every
|
||||
// mutator deep-clones and returns the next state so the scene and AI can look
|
||||
// ahead freely.
|
||||
//
|
||||
// Single-deal match: place tiles edge-to-edge on the triangular grid (touching
|
||||
// corners must match). Placing a tile scores its pip-sum plus bonuses; drawing
|
||||
// from the pool costs 5. The deal ends when a player empties their hand (they
|
||||
// score a going-out bonus of opponents' leftover pips) or the game is blocked
|
||||
// (lowest leftover pips wins that bonus). Highest score wins.
|
||||
|
||||
import {
|
||||
buildTileSet, handSizeFor, tileValue, isTripleTile,
|
||||
cellKey, vertKey, cellVertices, neighborCells,
|
||||
} from './TriominoesData.js';
|
||||
|
||||
export const HEXAGON_BONUS = 50; // closing a six-tile hexagon
|
||||
export const BRIDGE_BONUS = 0; // bridges not scored in this implementation
|
||||
export const GO_OUT_BONUS = 25; // flat bonus for emptying your hand
|
||||
export const DRAW_PENALTY = 5;
|
||||
export const MAX_DRAWS = 3; // per turn before a forced pass
|
||||
|
||||
function shuffle(arr) {
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
export function cloneState(s) {
|
||||
return JSON.parse(JSON.stringify(s));
|
||||
}
|
||||
|
||||
export function handPips(hand) {
|
||||
return hand.reduce((a, t) => a + tileValue(t), 0);
|
||||
}
|
||||
|
||||
export function createInitialState({ playerNames }) {
|
||||
const n = playerNames.length;
|
||||
const tiles = shuffle(buildTileSet());
|
||||
const hs = handSizeFor(n);
|
||||
|
||||
const players = playerNames.map((p) => ({
|
||||
name: p.name,
|
||||
isAI: !!p.isAI,
|
||||
avatar: p.avatar ?? null,
|
||||
hand: tiles.splice(0, hs),
|
||||
score: 0,
|
||||
draws: 0,
|
||||
}));
|
||||
|
||||
// The opener is whoever holds the highest tile (triple-fives ideal); ties fall
|
||||
// to the earliest seat. This mirrors the real "highest tile starts" rule.
|
||||
let opener = 0, openerVal = -1;
|
||||
players.forEach((p, i) => {
|
||||
const best = Math.max(...p.hand.map(tileValue));
|
||||
if (best > openerVal) { openerVal = best; opener = i; }
|
||||
});
|
||||
|
||||
return {
|
||||
players,
|
||||
current: opener,
|
||||
startPlayer: opener,
|
||||
pool: tiles, // remaining draw pile
|
||||
board: { cells: {}, verts: {}, vertCount: {} },
|
||||
phase: 'playing', // playing -> gameover
|
||||
consecutivePasses: 0,
|
||||
placedCount: 0,
|
||||
winner: null,
|
||||
lastEvent: null, // { type:'place'|'draw'|'pass'|'go-out'|'blocked', ... }
|
||||
};
|
||||
}
|
||||
|
||||
// ── queries ──────────────────────────────────────────────────────────────────
|
||||
export const isGameOver = (s) => s.phase === 'gameover';
|
||||
export const canDraw = (s) => s.pool.length > 0;
|
||||
export const isBoardEmpty = (s) => s.placedCount === 0;
|
||||
|
||||
// Empty cells that border at least one placed cell — the only places a tile may
|
||||
// go. On an empty board the sole frontier cell is the origin (0,0).
|
||||
export function frontierCells(s) {
|
||||
if (isBoardEmpty(s)) return [[0, 0]];
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const key of Object.keys(s.board.cells)) {
|
||||
const { r, c } = s.board.cells[key];
|
||||
for (const [nr, nc] of neighborCells(r, c)) {
|
||||
const k = cellKey(nr, nc);
|
||||
if (s.board.cells[k] || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push([nr, nc]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Can tile `t` (triple) be placed at cell (r,c) with the given rotation, i.e.
|
||||
// every already-fixed touching corner agrees? Returns the assigned corner-value
|
||||
// triple (clockwise) when legal, else null.
|
||||
export function tryPlacement(s, t, r, c, rot) {
|
||||
const verts = cellVertices(r, c);
|
||||
const vals = [0, 1, 2].map((i) => t.v[(i + rot) % 3]);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [vr, vx] = verts[i];
|
||||
const fixed = s.board.verts[vertKey(vr, vx)];
|
||||
if (fixed !== undefined && fixed !== vals[i]) return null;
|
||||
}
|
||||
return vals;
|
||||
}
|
||||
|
||||
// All legal placements for one player: [{ tileIndex, r, c, rot, vals }].
|
||||
// Distinct rotations that yield the same corner-values are de-duplicated per cell
|
||||
// so symmetric tiles don't flood the list.
|
||||
export function getLegalMoves(s, idx) {
|
||||
if (s.phase !== 'playing') return [];
|
||||
const hand = s.players[idx].hand;
|
||||
const frontier = frontierCells(s);
|
||||
const moves = [];
|
||||
hand.forEach((t, tileIndex) => {
|
||||
for (const [r, c] of frontier) {
|
||||
const seenVals = new Set();
|
||||
for (let rot = 0; rot < 3; rot++) {
|
||||
const vals = tryPlacement(s, t, r, c, rot);
|
||||
if (!vals) continue;
|
||||
const sig = vals.join('-');
|
||||
if (seenVals.has(sig)) continue;
|
||||
seenVals.add(sig);
|
||||
moves.push({ tileIndex, r, c, rot, vals });
|
||||
}
|
||||
}
|
||||
});
|
||||
return moves;
|
||||
}
|
||||
|
||||
export const hasLegalMove = (s, idx) => getLegalMoves(s, idx).length > 0;
|
||||
|
||||
// ── mutators ─────────────────────────────────────────────────────────────────
|
||||
function advance(s) {
|
||||
s.current = (s.current + 1) % s.players.length;
|
||||
}
|
||||
|
||||
function endGame(s) {
|
||||
s.phase = 'gameover';
|
||||
let best = -Infinity, w = 0;
|
||||
s.players.forEach((p, i) => { if (p.score > best) { best = p.score; w = i; } });
|
||||
s.winner = w;
|
||||
}
|
||||
|
||||
// Place a tile. `move` is one of getLegalMoves(). Returns the next state.
|
||||
export function playTile(s0, move) {
|
||||
if (s0.phase !== 'playing') return s0;
|
||||
const s = cloneState(s0);
|
||||
const player = s.players[s.current];
|
||||
const tile = player.hand[move.tileIndex];
|
||||
if (!tile) return s0;
|
||||
|
||||
const vals = tryPlacement(s, tile, move.r, move.c, move.rot);
|
||||
if (!vals) return s0; // illegal — ignore
|
||||
|
||||
const verts = cellVertices(move.r, move.c);
|
||||
let hexBonus = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const [vr, vx] = verts[i];
|
||||
const vk = vertKey(vr, vx);
|
||||
s.board.verts[vk] = vals[i];
|
||||
s.board.vertCount[vk] = (s.board.vertCount[vk] ?? 0) + 1;
|
||||
if (s.board.vertCount[vk] === 6) hexBonus += HEXAGON_BONUS;
|
||||
}
|
||||
s.board.cells[cellKey(move.r, move.c)] = {
|
||||
r: move.r, c: move.c, vals, owner: s.current,
|
||||
triple: isTripleTile(tile),
|
||||
};
|
||||
s.placedCount += 1;
|
||||
|
||||
player.hand.splice(move.tileIndex, 1);
|
||||
const gained = tileValue(tile) + hexBonus;
|
||||
player.score += gained;
|
||||
player.draws = 0;
|
||||
s.consecutivePasses = 0;
|
||||
s.lastEvent = { type: 'place', seat: s.current, r: move.r, c: move.c, gained, hexBonus };
|
||||
|
||||
if (player.hand.length === 0) {
|
||||
// Went out: collect everyone else's leftover pips as a bonus.
|
||||
const bonus = GO_OUT_BONUS + s.players.reduce(
|
||||
(a, p, i) => a + (i === s.current ? 0 : handPips(p.hand)), 0,
|
||||
);
|
||||
player.score += bonus;
|
||||
s.lastEvent = { type: 'go-out', seat: s.current, bonus };
|
||||
endGame(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
advance(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
// Draw one tile from the pool into the current player's hand (costs 5).
|
||||
export function drawTile(s0) {
|
||||
if (s0.phase !== 'playing' || s0.pool.length === 0) return s0;
|
||||
const s = cloneState(s0);
|
||||
const player = s.players[s.current];
|
||||
player.hand.push(s.pool.pop());
|
||||
player.draws += 1;
|
||||
player.score -= DRAW_PENALTY;
|
||||
s.lastEvent = { type: 'draw', seat: s.current };
|
||||
return s;
|
||||
}
|
||||
|
||||
// Current player gives up the turn. If everyone passes in a row with no pool
|
||||
// left, the deal is blocked.
|
||||
export function passTurn(s0) {
|
||||
if (s0.phase !== 'playing') return s0;
|
||||
const s = cloneState(s0);
|
||||
s.players[s.current].draws = 0;
|
||||
s.consecutivePasses += 1;
|
||||
s.lastEvent = { type: 'pass', seat: s.current };
|
||||
|
||||
if (s.pool.length === 0 && s.consecutivePasses >= s.players.length) {
|
||||
// Blocked: lightest hand wins the leftover-pip bonus.
|
||||
let lightest = 0, lightPips = Infinity;
|
||||
s.players.forEach((p, i) => {
|
||||
const pips = handPips(p.hand);
|
||||
if (pips < lightPips) { lightPips = pips; lightest = i; }
|
||||
});
|
||||
const bonus = s.players.reduce(
|
||||
(a, p, i) => a + (i === lightest ? 0 : handPips(p.hand)), 0,
|
||||
);
|
||||
s.players[lightest].score += bonus;
|
||||
s.lastEvent = { type: 'blocked', seat: lightest, bonus };
|
||||
endGame(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
advance(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
export function getWinners(s) {
|
||||
const max = Math.max(...s.players.map((p) => p.score));
|
||||
return s.players.map((p, i) => ({ i, sc: p.score }))
|
||||
.filter((x) => x.sc === max).map((x) => x.i);
|
||||
}
|
||||
|
|
@ -59,6 +59,7 @@ import FarkelGame from './games/farkel/FarkelGame.js';
|
|||
import StrategoGame from './games/stratego/StrategoGame.js';
|
||||
import KiitosGame from './games/kiitos/KiitosGame.js';
|
||||
import MonopolyGame from './games/monopoly/MonopolyGame.js';
|
||||
import TriominoesGame from './games/triominoes/TriominoesGame.js';
|
||||
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
|
|
@ -131,6 +132,7 @@ const config = {
|
|||
StrategoGame,
|
||||
KiitosGame,
|
||||
MonopolyGame,
|
||||
TriominoesGame,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
create() {
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame' };
|
||||
const slugDispatch = { backgammon: 'Backgammon', holdem: 'HoldemGame', blackjack: 'BlackjackGame', parchisi: 'ParchisiGame', yatzi: 'YatziGame', skipbo: 'SkipBoGame', phase10: 'Phase10Game', chinesecheckers: 'ChineseCheckersGame', gofish: 'GoFishGame', uno: 'UnoGame', craps: 'CrapsGame', roulette: 'RouletteGame', mexicantrain: 'MexicanTrainGame', hearts: 'HeartsGame', catan: 'CatanGame', tickettoride: 'TicketToRideGame', nerts: 'NertsGame', bingo: 'BingoGame', baccarat: 'BaccaratGame', dominion: 'DominionGame', checkers: 'CheckersGame', chess: 'ChessGame', wordle: 'WordleGame', scrabble: 'ScrabbleGame', ghost: 'GhostGame', wordladder: 'WordLadderGame', wordsearch: 'WordSearchGame', hangman: 'HangmanGame', sudoku: 'SudokuGame', othello: 'OthelloGame', go: 'GoGame', battleship: 'BattleshipGame', mastermind: 'MastermindGame', connect4: 'Connect4Game', boggle: 'BoggleGame', oldmaid: 'OldMaidGame', blokus: 'BlokusGame', spellingbee: 'SpellingBeeGame', minicrossword: 'MiniCrosswordGame', forbiddenisland: 'ForbiddenIslandGame', solitairetour: 'SolitaireTourGame', splendor: 'SplendorGame', tectonic: 'TectonicGame', labyrinth: 'LabyrinthGame', videopoker: 'VideoPokerGame', farkel: 'FarkelGame', stratego: 'StrategoGame', kiitos: 'KiitosGame', monopoly: 'MonopolyGame', triominoes: 'TriominoesGame' };
|
||||
if (slugDispatch[this.game.slug]) {
|
||||
this.scene.start(slugDispatch[this.game.slug], {
|
||||
game: this.game,
|
||||
|
|
|
|||
|
|
@ -384,7 +384,7 @@ export default class OpponentSelectScene extends Phaser.Scene {
|
|||
|
||||
// Skill control: pips always show the level; the +/- buttons appear only
|
||||
// when this opponent is selected. Enabled for games with a 1–5 AI skill.
|
||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego'].includes(this.gameDef.slug)) {
|
||||
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go', 'mastermind', 'connect4', 'boggle', 'forbiddenisland', 'labyrinth', 'stratego', 'triominoes'].includes(this.gameDef.slug)) {
|
||||
bio.style.webkitLineClamp = '1';
|
||||
|
||||
const skillRow = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -74,3 +74,4 @@ registerGame({ slug: 'farkel', name: 'Farkle', category: '
|
|||
registerGame({ slug: 'stratego', name: 'Stratego', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, hasTutorial: false, iconFrame: 46 });
|
||||
registerGame({ slug: 'kiitos', name: 'Kiitos', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 47 });
|
||||
registerGame({ slug: 'monopoly', name: 'Monopoly', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 48 });
|
||||
registerGame({ slug: 'triominoes', name: 'Tri-Ominoes', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 49 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue