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:
parent
2c0c7d7145
commit
aa9a3a0f6c
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
|
@ -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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue