feat: add Blokus board game with heuristic AI and Phaser UI

- Implement pure state engine for Blokus rules, move generation, and scoring
- Add board geometry, polyomino definitions, and orientation transforms
- Integrate heuristic single-ply AI with 5 skill levels (configurable noise/blunders)
- Create Phaser scene for board rendering, piece tray, ghost placement, and HUD
- Register game in server registry and update client routing/dispatch
This commit is contained in:
Brian Fertig 2026-05-31 20:16:21 -06:00
parent 2c0c7d7145
commit aa9a3a0f6c
7 changed files with 932 additions and 1 deletions

View File

@ -0,0 +1,93 @@
// Blokus AI — heuristic single-ply move scorer. No Phaser imports.
//
// Blokus has an enormous branching factor, so instead of a minimax search we
// enumerate every legal placement and score it greedily, then pick among the
// top candidates with skill-scaled blunder/noise (the suite's standard idiom).
import { SIZE, inBounds } from './BlokusBoard.js';
import { generateMoves } from './BlokusLogic.js';
const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
const CENTER = (SIZE - 1) / 2;
const SKILL_PROFILES = {
1: { topN: 6, blunder: 0.45, noise: 14, blockWeight: 0.0, delay: [800, 1300] },
2: { topN: 8, blunder: 0.28, noise: 9, blockWeight: 0.3, delay: [700, 1150] },
3: { topN: 10, blunder: 0.14, noise: 6, blockWeight: 0.6, delay: [600, 1000] },
4: { topN: 14, blunder: 0.05, noise: 3, blockWeight: 1.0, delay: [500, 900] },
5: { topN: 20, blunder: 0.00, noise: 0, blockWeight: 1.4, delay: [420, 820] },
};
function profileFor(skill) {
return SKILL_PROFILES[Math.max(1, Math.min(5, skill | 0))] ?? SKILL_PROFILES[3];
}
export function nextThinkDelay(skill) {
const [lo, hi] = profileFor(skill).delay;
return lo + Math.random() * (hi - lo);
}
function scoreMove(state, seat, move, blockWeight) {
const board = state.board;
const cells = move.cells;
const placed = new Set(cells.map(([r, c]) => r * SIZE + c));
const ownAt = (r, c) => inBounds(r, c) && (board[r][c] === seat || placed.has(r * SIZE + c));
// Dump big pieces early.
let score = cells.length * 6;
// Mobility — new diagonal "outlets" this placement opens for our colour.
const counted = new Set();
let outlets = 0;
for (const [r, c] of cells) {
for (const [dr, dc] of DIAG) {
const nr = r + dr, nc = c + dc;
if (!inBounds(nr, nc)) continue;
const k = nr * SIZE + nc;
if (board[nr][nc] !== null || placed.has(k) || counted.has(k)) continue;
if (ORTH.some(([or, oc]) => ownAt(nr + or, nc + oc))) continue; // can't host a future corner
counted.add(k);
outlets++;
}
}
score += outlets * 4;
// Reach toward the centre (contest territory).
let dsum = 0;
for (const [r, c] of cells) dsum += Math.abs(r - CENTER) + Math.abs(c - CENTER);
score -= (dsum / cells.length) * 0.5;
// Crowd opponents (skill-scaled).
if (blockWeight > 0) {
let block = 0;
for (const [r, c] of cells) {
for (const [dr, dc] of [...ORTH, ...DIAG]) {
const nr = r + dr, nc = c + dc;
if (inBounds(nr, nc) && board[nr][nc] !== null && board[nr][nc] !== seat) block++;
}
}
score += block * blockWeight * 3;
}
return score;
}
/** Pick a placement for `seat`, or null if it must pass. */
export function chooseMove(state, seat, skill = 3) {
const prof = profileFor(skill);
const moves = generateMoves(state, seat);
if (moves.length === 0) return null;
if (moves.length === 1) return moves[0];
if (Math.random() < prof.blunder) return moves[Math.floor(Math.random() * moves.length)];
const scored = moves.map((m) => ({ m, s: scoreMove(state, seat, m, prof.blockWeight) }));
scored.sort((a, b) => b.s - a.s);
const pool = scored.slice(0, Math.min(prof.topN, scored.length));
let best = pool[0];
let bestVal = -Infinity;
for (const cand of pool) {
const v = cand.s + (prof.noise ? (Math.random() * 2 - 1) * prof.noise : 0);
if (v > bestVal) { bestVal = v; best = cand; }
}
return best.m;
}

View File

@ -0,0 +1,143 @@
// Blokus — board geometry + polyomino shape data. No Phaser imports.
//
// The board is a 20×20 grid. Cells are addressed [row][col] with row/col in
// 0..19. Pixel geometry places the board on the left of the 1920×1080 canvas,
// leaving the right column for the piece tray and HUD.
import { GAME_HEIGHT } from '../../config.js';
export const SIZE = 20;
export const CELL = 47;
export const BOARD_PX = SIZE * CELL; // 940
export const GRID_X = 60;
export const GRID_Y = Math.round((GAME_HEIGHT - BOARD_PX) / 2); // 70
export function cellToWorld(r, c) {
return { x: GRID_X + c * CELL + CELL / 2, y: GRID_Y + r * CELL + CELL / 2 };
}
export function worldToCell(x, y) {
const c = Math.floor((x - GRID_X) / CELL);
const r = Math.floor((y - GRID_Y) / CELL);
if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) return null;
return { r, c };
}
export function inBounds(r, c) {
return r >= 0 && r < SIZE && c >= 0 && c < SIZE;
}
// Seat colours. Human is always seat 0 (Blue).
export const COLOR_PALETTE = [
{ key: 'blue', hex: 0x2d6cdf, dark: 0x1c4490, name: 'Blue' },
{ key: 'yellow', hex: 0xe6b800, dark: 0x8a6d00, name: 'Yellow' },
{ key: 'red', hex: 0xd23b3b, dark: 0x8f2424, name: 'Red' },
{ key: 'green', hex: 0x2f9e44, dark: 0x155724, name: 'Green' },
];
const TL = [0, 0], TR = [0, SIZE - 1], BR = [SIZE - 1, SIZE - 1], BL = [SIZE - 1, 0];
// Seat → starting corner cell, by player count. One colour per seat.
const CORNER_LAYOUTS = {
2: [TL, BR],
3: [TL, TR, BL],
4: [TL, TR, BR, BL],
};
export function cornersFor(playerCount) {
return CORNER_LAYOUTS[playerCount] ?? CORNER_LAYOUTS[4];
}
// ── Polyomino definitions ────────────────────────────────────────────────────
// Each piece is a base list of [row, col] cell offsets. 21 free polyominoes:
// 1 monomino + 1 domino + 2 trominoes + 5 tetrominoes + 12 pentominoes = 89 sq.
export const POLYOMINOES = [
{ id: 'I1', cells: [[0, 0]] },
{ id: 'I2', cells: [[0, 0], [0, 1]] },
{ id: 'I3', cells: [[0, 0], [0, 1], [0, 2]] },
{ id: 'V3', cells: [[0, 0], [1, 0], [1, 1]] },
{ id: 'I4', cells: [[0, 0], [0, 1], [0, 2], [0, 3]] },
{ id: 'O4', cells: [[0, 0], [0, 1], [1, 0], [1, 1]] },
{ id: 'T4', cells: [[0, 0], [0, 1], [0, 2], [1, 1]] },
{ id: 'L4', cells: [[0, 0], [1, 0], [2, 0], [2, 1]] },
{ id: 'S4', cells: [[0, 1], [0, 2], [1, 0], [1, 1]] },
{ id: 'F', cells: [[0, 1], [0, 2], [1, 0], [1, 1], [2, 1]] },
{ id: 'I5', cells: [[0, 0], [0, 1], [0, 2], [0, 3], [0, 4]] },
{ id: 'L5', cells: [[0, 0], [1, 0], [2, 0], [3, 0], [3, 1]] },
{ id: 'N', cells: [[0, 1], [1, 1], [2, 0], [2, 1], [3, 0]] },
{ id: 'P', cells: [[0, 0], [0, 1], [1, 0], [1, 1], [2, 0]] },
{ id: 'T5', cells: [[0, 0], [0, 1], [0, 2], [1, 1], [2, 1]] },
{ id: 'U', cells: [[0, 0], [0, 2], [1, 0], [1, 1], [1, 2]] },
{ id: 'V5', cells: [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2]] },
{ id: 'W', cells: [[0, 0], [1, 0], [1, 1], [2, 1], [2, 2]] },
{ id: 'X', cells: [[0, 1], [1, 0], [1, 1], [1, 2], [2, 1]] },
{ id: 'Y', cells: [[0, 1], [1, 0], [1, 1], [2, 1], [3, 1]] },
{ id: 'Z', cells: [[0, 0], [0, 1], [1, 1], [2, 1], [2, 2]] },
];
export const PIECE_BY_ID = Object.fromEntries(POLYOMINOES.map((p) => [p.id, p]));
export const PIECE_SIZE = Object.fromEntries(POLYOMINOES.map((p) => [p.id, p.cells.length]));
export const ALL_PIECE_IDS = POLYOMINOES.map((p) => p.id);
export const TOTAL_SQUARES = POLYOMINOES.reduce((n, p) => n + p.cells.length, 0); // 89
function normalize(cells) {
let minR = Infinity, minC = Infinity;
for (const [r, c] of cells) { if (r < minR) minR = r; if (c < minC) minC = c; }
return cells
.map(([r, c]) => [r - minR, c - minC])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
}
function keyOf(cells) {
return cells.map(([r, c]) => `${r},${c}`).join(';');
}
/** All unique rotation/reflection orientations of a base shape, normalized. */
export function orientations(cells) {
const seen = new Map();
let cur = cells.map(([r, c]) => [r, c]);
for (let flip = 0; flip < 2; flip++) {
let work = flip ? cur.map(([r, c]) => [r, -c]) : cur;
for (let rot = 0; rot < 4; rot++) {
const norm = normalize(work);
const k = keyOf(norm);
if (!seen.has(k)) seen.set(k, norm);
work = work.map(([r, c]) => [c, -r]); // rotate 90°
}
}
return [...seen.values()];
}
// Precomputed orientations per piece id: ORIENTATIONS[id] = [ [ [r,c], ... ], ... ]
export const ORIENTATIONS = Object.fromEntries(
POLYOMINOES.map((p) => [p.id, orientations(p.cells)]),
);
// Canonical-key → orientation index, per piece, for transform lookups.
const ORI_INDEX = Object.fromEntries(
POLYOMINOES.map((p) => [p.id, new Map(ORIENTATIONS[p.id].map((o, i) => [keyOf(o), i]))]),
);
function transformedIndex(pieceId, oriIdx, fn) {
const cur = ORIENTATIONS[pieceId][oriIdx];
const next = normalize(cur.map(fn));
return ORI_INDEX[pieceId].get(keyOf(next)) ?? oriIdx;
}
/** Orientation index after a 90° clockwise rotation of the current one. */
export function rotateOri(pieceId, oriIdx) {
return transformedIndex(pieceId, oriIdx, ([r, c]) => [c, -r]);
}
/** Orientation index after mirroring the current one horizontally. */
export function flipOri(pieceId, oriIdx) {
return transformedIndex(pieceId, oriIdx, ([r, c]) => [r, -c]);
}
/** Bounding-box dimensions { rows, cols } of an oriented piece. */
export function oriBounds(pieceId, oriIdx) {
const cells = ORIENTATIONS[pieceId][oriIdx];
let maxR = 0, maxC = 0;
for (const [r, c] of cells) { if (r > maxR) maxR = r; if (c > maxC) maxC = c; }
return { rows: maxR + 1, cols: maxC + 1 };
}

View File

@ -0,0 +1,458 @@
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 { playSound, SFX } from '../../ui/Sounds.js';
import { MusicPlayer } from '../../ui/MusicPlayer.js';
import {
SIZE, CELL, BOARD_PX, GRID_X, GRID_Y, cellToWorld, worldToCell,
COLOR_PALETTE, cornersFor, ALL_PIECE_IDS, PIECE_SIZE, ORIENTATIONS,
rotateOri, flipOri, oriBounds,
} from './BlokusBoard.js';
import {
createInitialState, applyPlacement, passTurn, placementCells, isLegal,
isFirstMove, hasAnyLegalPlacement, isGameOver, getWinners, scoreFor,
} from './BlokusLogic.js';
import { chooseMove, nextThinkDelay } from './BlokusAI.js';
const DEPTH = { felt: -1, grid: 0, corner: 1, piece: 10, ghost: 20, hud: 30, button: 35, banner: 50, modal: 80 };
// Right-hand panel layout.
const TRAY_X = 1035;
const TRAY_Y = 96;
const TRAY_COLS = 3;
const TRAY_SLOT_W = 188;
const TRAY_SLOT_H = 92;
const TRAY_CELL = 15;
const SIDE_X = 1750; // centre of the button / score column
export default class BlokusGame extends Phaser.Scene {
constructor() { super('BlokusGame'); }
init(data) {
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.gameOver = false;
this.selectedPieceId = null;
this.selectedOriIdx = 0;
this.hoverMove = null;
this.trayObjs = [];
this.scoreObjs = [];
}
create() {
new MusicPlayer(this, this.cache.json.get('music')?.tracks ?? []);
const playerCount = 1 + this.opponents.length;
this.gs = createInitialState({ playerCount });
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x10130f).setDepth(DEPTH.felt);
this.buildBoard();
this.pieceGfx = this.add.graphics().setDepth(DEPTH.piece);
this.ghostGfx = this.add.graphics().setDepth(DEPTH.ghost);
this.buildBoardInput();
this.buildScorePanels();
this.buildControls();
this.buildStatus();
this.input.keyboard.on('keydown-R', () => this.rotateSelection());
this.input.keyboard.on('keydown-F', () => this.flipSelection());
this.input.keyboard.on('keydown-ESC', () => this.clearSelection());
this.renderPieces();
this.renderTray();
this.renderScores();
this.driveTurn();
}
// ── Static board ────────────────────────────────────────────────────────────
buildBoard() {
const g = this.add.graphics().setDepth(DEPTH.grid);
g.fillStyle(0x1b1e17, 1);
g.fillRect(GRID_X, GRID_Y, BOARD_PX, BOARD_PX);
g.lineStyle(1, 0x3a3f33, 0.8);
for (let i = 0; i <= SIZE; i++) {
const x = GRID_X + i * CELL, y = GRID_Y + i * CELL;
g.lineBetween(x, GRID_Y, x, GRID_Y + BOARD_PX);
g.lineBetween(GRID_X, y, GRID_X + BOARD_PX, y);
}
g.lineStyle(3, COLORS.accent, 0.9);
g.strokeRect(GRID_X, GRID_Y, BOARD_PX, BOARD_PX);
// Mark each seat's starting corner in its colour.
const corners = cornersFor(this.gs.playerCount);
const cg = this.add.graphics().setDepth(DEPTH.corner);
corners.forEach(([r, c], seat) => {
const { x, y } = cellToWorld(r, c);
cg.lineStyle(3, COLOR_PALETTE[seat].hex, 0.9);
cg.strokeRect(x - CELL / 2 + 3, y - CELL / 2 + 3, CELL - 6, CELL - 6);
cg.fillStyle(COLOR_PALETTE[seat].hex, 0.18);
cg.fillRect(x - CELL / 2 + 3, y - CELL / 2 + 3, CELL - 6, CELL - 6);
});
}
buildBoardInput() {
const zone = this.add.zone(GRID_X, GRID_Y, BOARD_PX, BOARD_PX).setOrigin(0)
.setInteractive({ useHandCursor: false });
zone.on('pointermove', (p) => this.onBoardHover(p));
zone.on('pointerdown', (p) => this.onBoardClick(p));
zone.on('pointerout', () => { this.hoverMove = null; this.ghostGfx.clear(); });
}
// ── Cell drawing helpers ─────────────────────────────────────────────────────
fillCell(gfx, r, c, hex, alpha = 1, dark = null) {
const { x, y } = cellToWorld(r, c);
gfx.fillStyle(hex, alpha);
gfx.fillRoundedRect(x - CELL / 2 + 2, y - CELL / 2 + 2, CELL - 4, CELL - 4, 6);
if (dark !== null) {
gfx.lineStyle(2, dark, alpha);
gfx.strokeRoundedRect(x - CELL / 2 + 2, y - CELL / 2 + 2, CELL - 4, CELL - 4, 6);
}
}
renderPieces() {
this.pieceGfx.clear();
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const seat = this.gs.board[r][c];
if (seat === null) continue;
const col = COLOR_PALETTE[seat];
this.fillCell(this.pieceGfx, r, c, col.hex, 1, col.dark);
}
}
}
// ── Ghost preview ────────────────────────────────────────────────────────────
onBoardHover(p) {
if (this.gameOver || this.animating || !this.isHumanTurn() || !this.selectedPieceId) return;
const cell = worldToCell(p.x, p.y);
if (!cell) { this.hoverMove = null; this.ghostGfx.clear(); return; }
const cells = placementCells(this.selectedPieceId, this.selectedOriIdx, cell.r, cell.c);
const legal = isLegal(this.gs, 0, cells, isFirstMove(this.gs, 0));
this.hoverMove = { anchorR: cell.r, anchorC: cell.c, cells, legal };
this.drawGhost(cells, legal);
}
drawGhost(cells, legal) {
this.ghostGfx.clear();
const hex = legal ? 0x44d07a : 0xd0454a;
for (const [r, c] of cells) {
if (r < 0 || r >= SIZE || c < 0 || c >= SIZE) continue;
this.fillCell(this.ghostGfx, r, c, hex, 0.55, hex);
}
}
onBoardClick() {
if (this.gameOver || this.animating || !this.isHumanTurn()) return;
if (!this.selectedPieceId || !this.hoverMove || !this.hoverMove.legal) return;
const { anchorR, anchorC } = this.hoverMove;
const pieceId = this.selectedPieceId;
const oriIdx = this.selectedOriIdx;
this.clearSelection();
this.placePiece(0, { pieceId, oriIdx, anchorR, anchorC }, () => this.driveTurn());
}
// ── Tray ─────────────────────────────────────────────────────────────────────
renderTray() {
for (const o of this.trayObjs) o.destroy();
this.trayObjs = [];
const human = this.gs.players[0];
ALL_PIECE_IDS.forEach((pieceId, idx) => {
const col = idx % TRAY_COLS;
const row = Math.floor(idx / TRAY_COLS);
const sx = TRAY_X + col * TRAY_SLOT_W;
const sy = TRAY_Y + row * TRAY_SLOT_H;
const owned = human.remaining.has(pieceId);
const selected = owned && this.selectedPieceId === pieceId;
const container = this.add.container(sx, sy).setDepth(DEPTH.hud);
const bg = this.add.graphics();
if (selected) {
bg.fillStyle(COLORS.accent, 0.20);
bg.fillRoundedRect(0, 0, TRAY_SLOT_W - 8, TRAY_SLOT_H - 8, 8);
bg.lineStyle(2, COLORS.accent, 1);
bg.strokeRoundedRect(0, 0, TRAY_SLOT_W - 8, TRAY_SLOT_H - 8, 8);
}
container.add(bg);
// Draw the piece thumbnail (rotated to the selected orientation if selected).
const oriIdx = selected ? this.selectedOriIdx : 0;
const cells = ORIENTATIONS[pieceId][oriIdx];
const { rows, cols } = oriBounds(pieceId, oriIdx);
const ox = (TRAY_SLOT_W - 8) / 2 - (cols * TRAY_CELL) / 2;
const oy = (TRAY_SLOT_H - 8) / 2 - (rows * TRAY_CELL) / 2;
const tg = this.add.graphics();
const hex = owned ? COLOR_PALETTE[0].hex : 0x3a3f33;
const dark = owned ? COLOR_PALETTE[0].dark : 0x2a2e24;
for (const [r, c] of cells) {
tg.fillStyle(hex, owned ? 1 : 0.5);
tg.fillRoundedRect(ox + c * TRAY_CELL, oy + r * TRAY_CELL, TRAY_CELL - 2, TRAY_CELL - 2, 3);
tg.lineStyle(1, dark, owned ? 1 : 0.5);
tg.strokeRoundedRect(ox + c * TRAY_CELL, oy + r * TRAY_CELL, TRAY_CELL - 2, TRAY_CELL - 2, 3);
}
container.add(tg);
if (owned && !this.gameOver) {
const hit = this.add.zone(0, 0, TRAY_SLOT_W - 8, TRAY_SLOT_H - 8).setOrigin(0)
.setInteractive({ useHandCursor: true });
hit.on('pointerdown', () => this.onTrayClick(pieceId));
container.add(hit);
}
this.trayObjs.push(container);
});
}
onTrayClick(pieceId) {
if (!this.isHumanTurn() || this.animating) return;
if (this.selectedPieceId === pieceId) { this.clearSelection(); return; }
this.selectedPieceId = pieceId;
this.selectedOriIdx = 0;
this.hoverMove = null;
this.ghostGfx.clear();
this.renderTray();
this.updateStatus();
}
clearSelection() {
this.selectedPieceId = null;
this.selectedOriIdx = 0;
this.hoverMove = null;
this.ghostGfx.clear();
this.renderTray();
this.updateStatus();
}
rotateSelection() {
if (!this.selectedPieceId || !this.isHumanTurn()) return;
this.selectedOriIdx = rotateOri(this.selectedPieceId, this.selectedOriIdx);
this.refreshSelectionPreview();
}
flipSelection() {
if (!this.selectedPieceId || !this.isHumanTurn()) return;
this.selectedOriIdx = flipOri(this.selectedPieceId, this.selectedOriIdx);
this.refreshSelectionPreview();
}
refreshSelectionPreview() {
this.renderTray();
if (this.hoverMove) {
const { anchorR, anchorC } = this.hoverMove;
const cells = placementCells(this.selectedPieceId, this.selectedOriIdx, anchorR, anchorC);
const legal = isLegal(this.gs, 0, cells, isFirstMove(this.gs, 0));
this.hoverMove = { anchorR, anchorC, cells, legal };
this.drawGhost(cells, legal);
}
}
// ── Score panels + controls ──────────────────────────────────────────────────
buildScorePanels() {
this.scorePanels = [];
for (let seat = 0; seat < this.gs.players.length; seat++) {
const y = 96 + seat * 74;
const container = this.add.container(SIDE_X, y).setDepth(DEPTH.hud);
const bg = this.add.graphics();
container.add(bg);
const swatch = this.add.graphics();
container.add(swatch);
const name = this.add.text(-150, -18, '', { fontFamily: 'Righteous', fontSize: '20px', color: COLORS.textHex }).setOrigin(0, 0.5);
const detail = this.add.text(-150, 14, '', { fontFamily: '"Julius Sans One"', fontSize: '16px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
const score = this.add.text(150, 0, '', { fontFamily: 'Righteous', fontSize: '26px', color: COLORS.accentHex }).setOrigin(1, 0.5);
container.add([name, detail, score]);
this.scorePanels.push({ container, bg, swatch, name, detail, score });
}
}
playerName(seat) {
if (seat === 0) return auth.user?.username ?? 'You';
return this.opponents[seat - 1]?.name ?? `Player ${seat + 1}`;
}
renderScores() {
for (let seat = 0; seat < this.gs.players.length; seat++) {
const p = this.gs.players[seat];
const col = COLOR_PALETTE[seat];
const panel = this.scorePanels[seat];
const isTurn = !this.gameOver && this.gs.current === seat;
panel.bg.clear();
panel.bg.fillStyle(COLORS.panel, isTurn ? 0.95 : 0.6);
panel.bg.fillRoundedRect(-180, -32, 360, 64, 10);
panel.bg.lineStyle(isTurn ? 3 : 1, isTurn ? COLORS.accent : col.hex, isTurn ? 1 : 0.5);
panel.bg.strokeRoundedRect(-180, -32, 360, 64, 10);
panel.swatch.clear();
panel.swatch.fillStyle(col.hex, 1);
panel.swatch.fillRoundedRect(-172, -16, 28, 32, 5);
panel.name.setText(this.playerName(seat));
panel.detail.setText(p.out ? 'done' : `${p.remaining.size} pieces left`);
panel.score.setText(`${scoreFor(p)}`);
}
}
buildControls() {
this.rotateBtn = new Button(this, SIDE_X - 95, 560, '↻ Rotate', () => this.rotateSelection(), { width: 170, height: 48, fontSize: 20 }).setDepth(DEPTH.button);
this.flipBtn = new Button(this, SIDE_X + 95, 560, '↔ Flip', () => this.flipSelection(), { width: 170, height: 48, fontSize: 20 }).setDepth(DEPTH.button);
this.passBtn = new Button(this, SIDE_X, 624, 'Pass', () => this.humanPass(), { variant: 'ghost', width: 360, height: 48, fontSize: 20 }).setDepth(DEPTH.button);
new Button(this, SIDE_X - 95, 700, 'New', () => this.scene.restart(), { variant: 'ghost', width: 170, height: 44, fontSize: 18 }).setDepth(DEPTH.button);
new Button(this, SIDE_X + 95, 700, 'Leave', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 170, height: 44, fontSize: 18 }).setDepth(DEPTH.button);
}
buildStatus() {
this.statusText = this.add.text(GRID_X, GRID_Y + BOARD_PX + 16, '', {
fontFamily: 'Righteous', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0, 0).setDepth(DEPTH.hud);
this.bannerText = this.add.text(GRID_X + BOARD_PX / 2, GRID_Y + BOARD_PX / 2, '', {
fontFamily: 'Righteous', fontSize: '34px', color: COLORS.goldHex, align: 'center',
backgroundColor: '#000000bb', padding: { x: 24, y: 14 },
}).setOrigin(0.5).setDepth(DEPTH.banner).setVisible(false);
}
updateStatus() {
if (this.gameOver) { this.statusText.setText(''); return; }
if (this.isHumanTurn()) {
if (this.selectedPieceId) {
this.statusText.setText('Place on the board (green = legal). ↻ Rotate · ↔ Flip · Esc cancel');
} else if (isFirstMove(this.gs, 0)) {
this.statusText.setText('Your turn — pick a piece; your first must cover your corner.');
} else {
this.statusText.setText('Your turn — pick a piece from your tray.');
}
} else {
this.statusText.setText(`${this.playerName(this.gs.current)} is thinking…`);
}
}
showBanner(text, ms = 900, then = null) {
this.bannerText.setText(text).setVisible(true);
this.time.delayedCall(ms, () => { this.bannerText.setVisible(false); if (then) then(); });
}
// ── Turn flow ────────────────────────────────────────────────────────────────
isHumanTurn() {
return !this.gameOver && this.gs.current === 0 && !this.gs.players[0].out;
}
skillForSeat(seat) {
return this.opponents[seat - 1]?.skill ?? 3;
}
driveTurn() {
if (this.gameOver) return;
if (isGameOver(this.gs)) { this.endGame(); return; }
const seat = this.gs.current;
this.renderScores();
this.updateStatus();
this.updateButtons();
if (!hasAnyLegalPlacement(this.gs, seat)) {
this.animating = true;
this.showBanner(`${this.playerName(seat)} has no moves — passing`, 1000, () => {
this.gs = passTurn(this.gs, seat);
this.animating = false;
this.driveTurn();
});
return;
}
if (seat === 0) return; // wait for human input
this.animating = true;
this.time.delayedCall(nextThinkDelay(this.skillForSeat(seat)), () => this.runAI(seat));
}
runAI(seat) {
if (this.gameOver) return;
const mv = chooseMove(this.gs, seat, this.skillForSeat(seat));
if (!mv) { this.gs = passTurn(this.gs, seat); this.animating = false; this.driveTurn(); return; }
this.placePiece(seat, mv, () => { this.animating = false; this.driveTurn(); });
}
humanPass() {
if (!this.isHumanTurn() || this.animating) return;
this.clearSelection();
this.gs = passTurn(this.gs, 0);
this.driveTurn();
}
placePiece(seat, move, done) {
const next = applyPlacement(this.gs, seat, move.pieceId, move.oriIdx, move.anchorR, move.anchorC);
if (next === this.gs) { done(); return; } // illegal — should not happen
this.gs = next;
playSound(this, SFX.CARD_PLACE);
this.renderPieces();
this.renderTray();
this.renderScores();
// Brief pop on the freshly placed cells.
const cells = placementCells(move.pieceId, move.oriIdx, move.anchorR, move.anchorC);
const flash = this.add.graphics().setDepth(DEPTH.ghost);
for (const [r, c] of cells) this.fillCell(flash, r, c, 0xffffff, 0.5);
this.tweens.add({ targets: flash, alpha: 0, duration: 280, ease: 'Quad.easeOut', onComplete: () => flash.destroy() });
this.time.delayedCall(180, done);
}
updateButtons() {
const human = this.isHumanTurn();
const canManip = human && !!this.selectedPieceId;
this.rotateBtn?.setAlpha(canManip ? 1 : 0.4);
this.flipBtn?.setAlpha(canManip ? 1 : 0.4);
this.passBtn?.setAlpha(human ? 1 : 0.4);
}
// ── Game over ────────────────────────────────────────────────────────────────
endGame() {
if (this.gameOver) return;
this.gameOver = true;
this.clearSelection();
this.renderScores();
this.updateStatus();
const winners = new Set(getWinners(this.gs));
this.recordResult(winners);
const cx = GRID_X + BOARD_PX / 2, cy = GRID_Y + BOARD_PX / 2;
const order = this.gs.players
.map((p) => ({ seat: p.seat, score: scoreFor(p) }))
.sort((a, b) => b.score - a.score);
const panelH = 150 + order.length * 50;
this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setDepth(DEPTH.modal).setInteractive();
this.add.rectangle(cx, cy, 640, panelH, COLORS.panel, 1).setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.modal + 1);
const humanWon = winners.has(0);
this.add.text(cx, cy - panelH / 2 + 44,
humanWon && winners.size === 1 ? '🎉 You win!' : humanWon ? 'You tie for first!' : 'Game over',
{ fontFamily: 'Righteous', fontSize: '38px', color: humanWon ? '#ffd700' : COLORS.textHex },
).setOrigin(0.5).setDepth(DEPTH.modal + 2);
let rowY = cy - panelH / 2 + 104;
for (const { seat, score } of order) {
const win = winners.has(seat);
const col = win ? COLORS.goldHex : COLORS.textHex;
this.add.text(cx - 270, rowY, `${win ? '★ ' : ' '}${this.playerName(seat)}`,
{ fontFamily: 'Righteous', fontSize: '24px', color: col }).setOrigin(0, 0.5).setDepth(DEPTH.modal + 2);
this.add.text(cx + 270, rowY, `${score}`,
{ fontFamily: 'Righteous', fontSize: '26px', color: col }).setOrigin(1, 0.5).setDepth(DEPTH.modal + 2);
rowY += 50;
}
new Button(this, cx - 100, cy + panelH / 2 - 40, 'Play Again', () => this.scene.restart(),
{ width: 180, fontSize: 22 }).setDepth(DEPTH.modal + 2);
new Button(this, cx + 100, cy + panelH / 2 - 40, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 180, fontSize: 22 }).setDepth(DEPTH.modal + 2);
}
async recordResult(winners) {
try {
const scores = this.gs.players.map(scoreFor);
const result = winners.has(0) ? (winners.size === 1 ? 'win' : 'draw') : 'loss';
await api.post('/history/single-player', {
slug: 'blokus', score: scores[0], opponentScores: scores.slice(1), result,
});
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,234 @@
// Blokus — pure state engine. No Phaser imports.
//
// One colour per seat. A piece is legal when every cell is empty and in bounds
// and: the player's FIRST piece covers their corner; every later piece touches
// one of that player's own cells diagonally but never shares an edge with one.
// A player who cannot move passes and is out for the rest of the game (their own
// pieces never change, so no future move can open up). Score = (unplaced
// squares), +15 for placing all pieces, +20 if the last piece was the monomino;
// highest score wins.
import {
SIZE, inBounds, cornersFor, ORIENTATIONS, ALL_PIECE_IDS, PIECE_SIZE,
COLOR_PALETTE,
} from './BlokusBoard.js';
const ORTH = [[-1, 0], [1, 0], [0, -1], [0, 1]];
const DIAG = [[-1, -1], [-1, 1], [1, -1], [1, 1]];
export function createInitialState({ playerCount = 4, seed } = {}) {
const n = Math.max(2, Math.min(4, playerCount));
const board = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
const players = [];
for (let seat = 0; seat < n; seat++) {
players.push({
seat,
color: COLOR_PALETTE[seat].key,
remaining: new Set(ALL_PIECE_IDS),
placedCount: 0,
lastPieceSize: 0,
out: false,
});
}
return {
playerCount: n,
board,
players,
current: 0,
turnCount: 0,
phase: 'play',
log: [],
seed: seed ?? null,
};
}
export function cloneState(state) {
return {
playerCount: state.playerCount,
board: state.board.map((row) => row.slice()),
players: state.players.map((p) => ({
seat: p.seat,
color: p.color,
remaining: new Set(p.remaining),
placedCount: p.placedCount,
lastPieceSize: p.lastPieceSize,
out: p.out,
})),
current: state.current,
turnCount: state.turnCount,
phase: state.phase,
log: state.log.map((e) => ({ ...e })),
seed: state.seed,
};
}
export function isFirstMove(state, seat) {
return state.players[seat].placedCount === 0;
}
/** Absolute cells for an oriented piece whose normalized origin sits at (anchorR, anchorC). */
export function placementCells(pieceId, oriIdx, anchorR, anchorC) {
const ori = ORIENTATIONS[pieceId][oriIdx];
return ori.map(([dr, dc]) => [anchorR + dr, anchorC + dc]);
}
/** Is `cells` a legal placement of `seat`'s next piece? */
export function isLegal(state, seat, cells, first) {
const board = state.board;
for (const [r, c] of cells) {
if (!inBounds(r, c) || board[r][c] !== null) return false;
}
if (first) {
const [cr, cc] = cornersFor(state.playerCount)[seat];
return cells.some(([r, c]) => r === cr && c === cc);
}
let touchesDiag = false;
for (const [r, c] of cells) {
for (const [dr, dc] of ORTH) {
const ar = r + dr, ac = c + dc;
if (inBounds(ar, ac) && board[ar][ac] === seat) return false; // edge-shares own colour
}
if (!touchesDiag) {
for (const [dr, dc] of DIAG) {
const ar = r + dr, ac = c + dc;
if (inBounds(ar, ac) && board[ar][ac] === seat) { touchesDiag = true; break; }
}
}
}
return touchesDiag;
}
/** Empty cells diagonal to one of `seat`'s pieces and not edge-adjacent to one. */
export function anchorsFor(state, seat) {
const board = state.board;
const result = [];
const seen = new Set();
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (board[r][c] !== seat) continue;
for (const [dr, dc] of DIAG) {
const nr = r + dr, nc = c + dc;
if (!inBounds(nr, nc) || board[nr][nc] !== null) continue;
const k = nr * SIZE + nc;
if (seen.has(k)) continue;
const edgeOwn = ORTH.some(([or, oc]) => {
const ar = nr + or, ac = nc + oc;
return inBounds(ar, ac) && board[ar][ac] === seat;
});
if (edgeOwn) continue;
seen.add(k);
result.push([nr, nc]);
}
}
}
return result;
}
/**
* Generate legal placements for `seat`. Restricts the search to placements that
* cover an anchor cell (the corner on the first move). Pass { stopAtFirst:true }
* for a fast "can this seat move at all?" check. Returns deduped move objects
* { pieceId, oriIdx, anchorR, anchorC, cells }.
*/
export function generateMoves(state, seat, { stopAtFirst = false } = {}) {
const player = state.players[seat];
const first = player.placedCount === 0;
const anchorCells = first ? [cornersFor(state.playerCount)[seat]] : anchorsFor(state, seat);
const moves = [];
if (anchorCells.length === 0) return moves;
const seen = new Set();
for (const pieceId of player.remaining) {
const oris = ORIENTATIONS[pieceId];
for (let oriIdx = 0; oriIdx < oris.length; oriIdx++) {
const ori = oris[oriIdx];
for (const [ar, ac] of anchorCells) {
for (const [dr, dc] of ori) {
const anchorR = ar - dr, anchorC = ac - dc;
const cells = ori.map(([r, c]) => [anchorR + r, anchorC + c]);
if (!isLegal(state, seat, cells, first)) continue;
const key = `${pieceId}:${anchorR},${anchorC}:${oriIdx}`;
if (seen.has(key)) continue;
seen.add(key);
moves.push({ pieceId, oriIdx, anchorR, anchorC, cells });
if (stopAtFirst) return moves;
}
}
}
}
return moves;
}
export function hasAnyLegalPlacement(state, seat) {
if (state.players[seat].out) return false;
return generateMoves(state, seat, { stopAtFirst: true }).length > 0;
}
function advanceTurn(state) {
const N = state.players.length;
let next = state.current;
for (let i = 0; i < N; i++) {
next = (next + 1) % N;
if (!state.players[next].out) { state.current = next; state.turnCount += 1; return; }
}
// Nobody left who can act.
state.phase = 'gameOver';
}
function checkAllOut(state) {
if (state.players.every((p) => p.out)) state.phase = 'gameOver';
}
/** Place `seat`'s piece. Returns a new state, or the same state if illegal. */
export function applyPlacement(state, seat, pieceId, oriIdx, anchorR, anchorC) {
if (state.phase !== 'play' || state.current !== seat) return state;
const player = state.players[seat];
if (!player.remaining.has(pieceId)) return state;
const first = player.placedCount === 0;
const cells = placementCells(pieceId, oriIdx, anchorR, anchorC);
if (!isLegal(state, seat, cells, first)) return state;
const next = cloneState(state);
const np = next.players[seat];
for (const [r, c] of cells) next.board[r][c] = seat;
np.remaining.delete(pieceId);
np.placedCount += 1;
np.lastPieceSize = cells.length;
next.log.push({ kind: 'place', seat, pieceId, size: cells.length });
if (np.remaining.size === 0) np.out = true; // finished — nothing left to play
advanceTurn(next);
checkAllOut(next);
return next;
}
/** Mark `seat` as permanently passed/out and advance. */
export function passTurn(state, seat) {
if (state.phase !== 'play' || state.current !== seat) return state;
const next = cloneState(state);
next.players[seat].out = true;
next.log.push({ kind: 'pass', seat });
advanceTurn(next);
checkAllOut(next);
return next;
}
export function remainingSquares(player) {
let n = 0;
for (const id of player.remaining) n += PIECE_SIZE[id];
return n;
}
export function scoreFor(player) {
const rem = remainingSquares(player);
if (rem === 0) return player.lastPieceSize === 1 ? 20 : 15;
return -rem;
}
export function getWinners(state) {
const scores = state.players.map(scoreFor);
const max = Math.max(...scores);
return state.players.filter((_, i) => scores[i] === max).map((p) => p.seat);
}
export function isGameOver(state) {
return state.phase === 'gameOver';
}

View File

@ -46,6 +46,7 @@ import MastermindGame from './games/mastermind/MastermindGame.js';
import Connect4Game from './games/connect4/Connect4Game.js'; import Connect4Game from './games/connect4/Connect4Game.js';
import BoggleGame from './games/boggle/BoggleGame.js'; import BoggleGame from './games/boggle/BoggleGame.js';
import OldMaidGame from './games/oldmaid/OldMaidGame.js'; import OldMaidGame from './games/oldmaid/OldMaidGame.js';
import BlokusGame from './games/blokus/BlokusGame.js';
const config = { const config = {
type: Phaser.AUTO, type: Phaser.AUTO,
@ -105,6 +106,7 @@ const config = {
Connect4Game, Connect4Game,
BoggleGame, BoggleGame,
OldMaidGame, OldMaidGame,
BlokusGame,
], ],
}; };

View File

@ -21,7 +21,7 @@ export default class GameRoomScene extends Phaser.Scene {
} }
create() { 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' }; 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' };
if (slugDispatch[this.game.slug]) { if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], { this.scene.start(slugDispatch[this.game.slug], {
game: this.game, game: this.game,

View File

@ -61,3 +61,4 @@ registerGame({ slug: 'mastermind', name: 'Mastermind', category: 'ta
registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 33 }); registerGame({ slug: 'connect4', name: 'Connect 4', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, iconFrame: 33 });
registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 34 }); registerGame({ slug: 'boggle', name: 'Boggle', category: 'word', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 34 });
registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 }); registerGame({ slug: 'oldmaid', name: 'Old Maid', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, iconFrame: 35 });
registerGame({ slug: 'blokus', name: 'Blokus', category: 'tabletop', minPlayers: 2, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, iconFrame: 36 });