feat: add Othello and Go games with AI and Phaser UI

- Implement pure game logic for Othello (8x8) and Go (9x9)
- Add configurable AI opponents (skill 1-5) using heuristic look-ahead and alpha-beta minimax
- Build Phaser scenes with polished UI, stone/disc rendering, capture/flip animations, territory scoring, and end-game panels
- Register games in server registry and update client routing, opponent selection, and scene registration
This commit is contained in:
Brian Fertig 2026-05-30 18:59:48 -06:00
parent 8ccb100678
commit 618d3d31c4
10 changed files with 1783 additions and 3 deletions

160
public/src/games/go/GoAI.js Normal file
View File

@ -0,0 +1,160 @@
// Go AI — greedy heuristic with limited look-ahead (no full MCTS for simplicity).
// Suitable for casual 9×9 play.
import {
SIZE, getGroup, getLiberties, findCaptures, isValidMove, getValidMoves,
applyMove, applyPass, getFinalScore, boardHash,
} from './GoLogic.js';
function other(color) { return color === 'black' ? 'white' : 'black'; }
const SKILL_PROFILES = {
1: { lookahead: 0, blunder: 0.70, topN: 1, delay: [900, 1500] },
2: { lookahead: 0, blunder: 0.45, topN: 3, delay: [800, 1300] },
3: { lookahead: 1, blunder: 0.20, topN: 5, delay: [650, 1100] },
4: { lookahead: 1, blunder: 0.05, topN: 8, delay: [500, 950] },
5: { lookahead: 2, blunder: 0.00, topN: 12, delay: [400, 800] },
};
// 9×9 hoshi (star) positions for early-game bonus
const HOSHI = [[2,2],[2,6],[6,2],[6,6],[4,4]];
const HOSHI_SET = new Set(HOSHI.map(([r,c]) => `${r},${c}`));
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);
}
// Score a single candidate move for `color`.
function scoreMove(state, r, c, color) {
const opp = other(color);
const board = state.board;
let score = 0;
// 1. Captures: very valuable
const caps = findCaptures(board, r, c, color);
const capCount = caps.reduce((n, g) => n + g.size, 0);
score += capCount * 120;
// 2. Simulate board after placement to evaluate own/opponent liberties
const testBoard = board.map(row => [...row]);
testBoard[r][c] = color;
for (const g of caps) for (const k of g) { const [gr, gc] = k.split(',').map(Number); testBoard[gr][gc] = null; }
// 3. Atari attack: put opponent group in atari (1 liberty)
const oppChecked = new Set();
for (const dr of [-1,0,1]) for (const dc of [-1,0,1]) {
if (Math.abs(dr) + Math.abs(dc) !== 1) continue;
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE) continue;
if (testBoard[nr][nc] !== opp) continue;
const key = `${nr},${nc}`;
if (oppChecked.has(key)) continue;
const g = getGroup(testBoard, nr, nc);
for (const k of g) oppChecked.add(k);
const libs = getLiberties(testBoard, g);
if (libs === 1) score += 60; // atari
else if (libs === 2) score += 15; // semi-threatening
}
// 4. Own group defense: save own groups in atari
const ownGroup = getGroup(testBoard, r, c);
// Check own neighbors for groups that were in atari before this move
const ownChecked = new Set();
for (const dr of [-1,0,1]) for (const dc of [-1,0,1]) {
if (Math.abs(dr) + Math.abs(dc) !== 1) continue;
const nr = r + dr, nc = c + dc;
if (nr < 0 || nr >= SIZE || nc < 0 || nc >= SIZE) continue;
if (board[nr][nc] !== color) continue;
const key = `${nr},${nc}`;
if (ownChecked.has(key)) continue;
const oldGroup = getGroup(board, nr, nc);
for (const k of oldGroup) ownChecked.add(k);
const oldLibs = getLiberties(board, oldGroup);
const newGroup = getGroup(testBoard, nr, nc);
const newLibs = getLiberties(testBoard, newGroup);
if (oldLibs === 1 && newLibs > 1) score += 80; // saved from capture
else if (oldLibs <= 2 && newLibs > oldLibs) score += 20; // improved breathing room
}
// 5. Territory heuristic: connected empty space adjacent to own stone
const oppNeighbor = new Set();
const ownNeighbor = new Set();
for (const dr of [-1,0,1]) for (const dc of [-1,0,1]) {
if (Math.abs(dr)+Math.abs(dc) !== 1) continue;
const nr = r+dr, nc = c+dc;
if (nr<0||nr>=SIZE||nc<0||nc>=SIZE) continue;
if (testBoard[nr][nc] === opp) oppNeighbor.add(`${nr},${nc}`);
if (testBoard[nr][nc] === color) ownNeighbor.add(`${nr},${nc}`);
}
score += ownNeighbor.size * 8;
// 6. Star point bonus early game
const totalStones = board.flat().filter(Boolean).length;
if (totalStones < 20 && HOSHI_SET.has(`${r},${c}`)) score += 30;
// 7. Slight center preference early game
if (totalStones < 30) {
const dist = Math.abs(r - 4) + Math.abs(c - 4);
score += Math.max(0, 6 - dist);
}
// 8. Avoid self-atari (placing in a position with 1 liberty after placement)
if (getLiberties(testBoard, ownGroup) <= 1) score -= 80;
return score;
}
export function chooseMove(state, aiColor, skill) {
const prof = profileFor(skill);
const moves = getValidMoves(state, aiColor);
if (moves.length === 0) return null;
if (Math.random() < prof.blunder) return moves[Math.floor(Math.random() * moves.length)];
// Score all moves, pick topN candidates
const scored = moves.map(m => ({ m, score: scoreMove(state, m.r, m.c, aiColor) }));
scored.sort((a, b) => b.score - a.score);
const topN = Math.min(prof.topN, scored.length);
const candidates = scored.slice(0, topN).map(x => x.m);
if (prof.lookahead === 0 || candidates.length === 0) {
return candidates[0] ?? null;
}
// Limited look-ahead: for each candidate, score what the opponent plays next
let bestMove = candidates[0], bestFinal = -Infinity;
for (const m of candidates) {
const ns = applyMove(state, m.r, m.c);
const oppMoves = getValidMoves(ns, other(aiColor));
let oppBest = -Infinity;
for (const om of oppMoves.slice(0, 6)) {
const ns2 = applyMove(ns, om.r, om.c);
// Evaluate final score delta as proxy
const finalScore = getFinalScore(ns2);
const delta = aiColor === 'black'
? (ns2.captures.black - ns2.captures.white)
: (ns2.captures.white - ns2.captures.black);
if (delta > oppBest) oppBest = delta;
}
if (prof.lookahead >= 2) {
// One more ply
const ns2 = applyMove(ns, oppMoves[0]?.r ?? m.r, oppMoves[0]?.c ?? m.c);
const myMoves2 = getValidMoves(ns2, aiColor);
let myBest2 = -Infinity;
for (const mm of myMoves2.slice(0, 4)) {
const s = scoreMove(ns2, mm.r, mm.c, aiColor);
if (s > myBest2) myBest2 = s;
}
if (myBest2 > bestFinal) { bestFinal = myBest2; bestMove = m; }
} else {
if (-oppBest > bestFinal) { bestFinal = -oppBest; bestMove = m; }
}
}
return bestMove;
}

View File

@ -0,0 +1,649 @@
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 {
SIZE, KOMI, createInitialState, getValidMoves, findCaptures, getGroup,
getLiberties, isValidMove, applyMove, applyPass, isGameOver,
scoreTerritory, getFinalScore, getWinner,
} from './GoLogic.js';
import { chooseMove, nextThinkDelay } from './GoAI.js';
// ── Layout ─────────────────────────────────────────────────────────────────────
const SQ = 86; // distance between grid lines
const MARGIN = 52; // padding inside frame to first line
const INNER = (SIZE - 1) * SQ + MARGIN * 2; // 688 + 104 = 792
const FRAME = 30;
const FULL = INNER + FRAME * 2; // 852
const BX = Math.round(GAME_WIDTH / 2 - FULL / 2); // 534
const BY = Math.round(GAME_HEIGHT / 2 - FULL / 2); // 114
const GX = BX + FRAME + MARGIN; // x of intersection (0,0)
const GY = BY + FRAME + MARGIN; // y of intersection (0,0)
const SR = Math.round(SQ * 0.42); // stone radius ≈36
const DEPTH = { board: 0, stone: 10, hint: 8, territory: 6, overlay: 20, ui: 50, banner: 60 };
// ── Colors ─────────────────────────────────────────────────────────────────────
const C = {
wood: 0xd4a84b,
woodDk: 0xb88c36,
grain: 0xc49840,
line: 0x4a3010,
frame: 0x3a2010,
frameLt: 0x6b3a1a,
frameLn: 0x8b5020,
bFill: 0x1c1a26,
bHi: 0x5a5870,
bEdge: 0x0d0c14,
wFill: 0xf8f4e8,
wHi: 0xffffff,
wEdge: 0xd8d0b8,
hint: 0xffd700,
selGold: 0xffd700,
};
// Traditional 9×9 hoshi (star points)
const HOSHI = [[2,2],[2,6],[6,2],[6,6],[4,4]];
export default class GoGame extends Phaser.Scene {
constructor() { super('GoGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.stoneObjs = []; // stoneObjs[r][c] = Graphics | null
this.hintObjs = [];
this.overlayObjs = [];
this.terrObjs = []; // territory reveal graphics
this.opponentPortrait = null;
this.captureBlackText = null;
this.captureWhiteText = null;
this.turnText = null;
this.passCount = 0;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.buildPlayfield();
this.buildBoard();
this.buildUI();
this.buildPlayerCards();
this.initGame();
}
// ── Construction ──────────────────────────────────────────────────────────────
buildParticleTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('goParticle', 10, 10);
g.destroy();
}
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.board - 2);
} else if (pf?.fallbackColor) {
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color)
.setDepth(DEPTH.board - 2);
}
}
buildBoard() {
const g = this.add.graphics().setDepth(DEPTH.board);
// Wood frame
g.fillStyle(C.frame, 1);
g.fillRoundedRect(BX, BY, FULL, FULL, 12);
g.lineStyle(3, C.frameLt, 1);
g.strokeRoundedRect(BX + 5, BY + 5, FULL - 10, FULL - 10, 9);
g.lineStyle(1, C.frameLn, 0.6);
g.strokeRect(BX + FRAME - 2, BY + FRAME - 2, INNER + 4, INNER + 4);
// Wood board surface
g.fillStyle(C.wood, 1);
g.fillRect(BX + FRAME, BY + FRAME, INNER, INNER);
// Subtle grain: a few lighter horizontal bands
g.fillStyle(C.grain, 0.18);
for (let i = 0; i < 6; i++) {
const gy = BY + FRAME + i * (INNER / 5.5);
g.fillRect(BX + FRAME, gy, INNER, 12);
}
// Grid lines — thin inner, slightly thicker outer border
g.lineStyle(1, C.line, 0.75);
for (let i = 0; i < SIZE; i++) {
const x = GX + i * SQ, y = GY + i * SQ;
g.lineBetween(x, GY, x, GY + (SIZE - 1) * SQ);
g.lineBetween(GX, y, GX + (SIZE - 1) * SQ, y);
}
// Slightly thicker outer border
g.lineStyle(2, C.line, 0.9);
g.strokeRect(GX, GY, (SIZE - 1) * SQ, (SIZE - 1) * SQ);
// Hoshi (star points)
g.fillStyle(C.line, 0.9);
for (const [r, c] of HOSHI) g.fillCircle(GX + c * SQ, GY + r * SQ, 5);
// Coordinate labels: AI along bottom, 91 along left
for (let c = 0; c < SIZE; c++) {
this.add.text(GX + c * SQ, BY + FRAME + INNER + 18, String.fromCharCode(65 + c), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
for (let r = 0; r < SIZE; r++) {
this.add.text(BX + FRAME - 18, GY + r * SQ, String(SIZE - r), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
// Invisible click zones for each intersection
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const { x, y } = this.intToWorld(r, c);
const zone = this.add.zone(x, y, SQ - 4, SQ - 4)
.setInteractive({ useHandCursor: true }).setDepth(DEPTH.hint);
zone.on('pointerdown', () => this.onIntersectionClick(r, c));
zone.on('pointerover', () => this.onIntersectionHover(r, c, true));
zone.on('pointerout', () => this.onIntersectionHover(r, c, false));
}
}
}
buildUI() {
const cx = BX + FULL / 2;
this.turnText = this.add.text(cx, BY + FULL + 14, '', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
const btnX = BX + FULL + FRAME + 90;
new Button(this, btnX, BY + 60, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 }).setDepth(DEPTH.ui);
new Button(this, btnX, BY + 124, 'New',
() => this.initGame(),
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 }).setDepth(DEPTH.ui);
this.passBtn = new Button(this, btnX, BY + 200, 'Pass',
() => this.onPlayerPass(),
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 });
this.passBtn.setDepth(DEPTH.ui);
new Button(this, btnX, BY + 264, 'Resign',
() => this.onPlayerResign(),
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 }).setDepth(DEPTH.ui);
}
buildPlayerCards() {
const opp = this.opponents[0];
const r = 78;
const depth = DEPTH.ui;
const avatarX = BX / 2;
// Opponent (white stones)
const oppAY = BY + r + 20;
this.add.circle(avatarX, oppAY, r + 5, C.frame).setDepth(depth);
this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1);
this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(depth + 2);
this.add.text(avatarX, oppAY + r + 40, 'captures:', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5, 0).setDepth(depth + 2);
this.captureWhiteText = this.add.text(avatarX, oppAY + r + 58, '0', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(depth + 2);
// Player (black stones)
const plrAY = BY + FULL - r - 20;
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Go');
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 1).setDepth(depth + 2);
this.add.text(avatarX, plrAY - r - 40, 'captures:', {
fontFamily: '"Julius Sans One"', fontSize: '14px', color: COLORS.mutedHex,
}).setOrigin(0.5, 1).setDepth(depth + 2);
this.captureBlackText = this.add.text(avatarX, plrAY - r - 58, '0', {
fontFamily: '"Julius Sans One"', fontSize: '22px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(depth + 2);
}
playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); }
// ── Game flow ─────────────────────────────────────────────────────────────────
initGame() {
this.clearStones();
this.clearHints();
this.clearOverlays();
this.clearTerritory();
this.animating = false;
this.gs = createInitialState();
this.renderAll();
this.showTurnBanner('Your Turn — Black');
if (this.passBtn) this.passBtn.setEnabled(true);
}
renderAll() {
this.clearStones();
this.clearHints();
this.renderStones();
this.updateCaptureDisplay();
this.updateTurnText();
}
updateTurnText() {
if (!this.turnText) return;
if (this.gs.phase === 'game_over') { this.turnText.setText(''); return; }
this.turnText.setText(this.gs.turn === 'black' ? 'Your move' : 'Opponent thinking…');
}
updateCaptureDisplay() {
this.captureBlackText?.setText(String(this.gs.captures.black));
this.captureWhiteText?.setText(String(this.gs.captures.white));
}
// ── Stone rendering ───────────────────────────────────────────────────────────
intToWorld(r, c) { return { x: GX + c * SQ, y: GY + r * SQ }; }
clearStones() {
if (this.stoneObjs.length) {
for (const row of this.stoneObjs)
for (const g of row) { if (g) { this.tweens.killTweensOf(g); g.destroy(); } }
}
this.stoneObjs = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
}
clearHints() {
for (const o of this.hintObjs) o.destroy();
this.hintObjs = [];
}
clearOverlays() {
for (const o of this.overlayObjs) o.destroy();
this.overlayObjs = [];
}
clearTerritory() {
for (const o of this.terrObjs) o.destroy();
this.terrObjs = [];
}
renderStones() {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const cell = this.gs.board[r][c];
if (!cell) continue;
const { x, y } = this.intToWorld(r, c);
const g = this.add.graphics().setPosition(x, y).setDepth(DEPTH.stone);
this.drawStoneGraphics(g, cell);
this.stoneObjs[r][c] = g;
}
}
}
drawStoneGraphics(g, color) {
g.clear();
const fill = color === 'black' ? C.bFill : C.wFill;
const edge = color === 'black' ? C.bEdge : C.wEdge;
const hi = color === 'black' ? C.bHi : C.wHi;
// Shadow
g.fillStyle(0x000000, 0.32);
g.fillCircle(3, 5, SR);
// Edge ring
g.fillStyle(edge, 1);
g.fillCircle(0, 0, SR);
// Main fill
g.fillStyle(fill, 1);
g.fillCircle(0, 0, SR - 2);
// Top-left highlight arc
g.lineStyle(4, hi, color === 'black' ? 0.38 : 0.72);
g.beginPath();
g.arc(0, 0, SR - 7, Phaser.Math.DegToRad(205), Phaser.Math.DegToRad(315));
g.strokePath();
}
// ── Hover ghost stone ─────────────────────────────────────────────────────────
onIntersectionHover(r, c, entering) {
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'black') return;
if (this._ghostGfx) { this._ghostGfx.destroy(); this._ghostGfx = null; }
if (!entering || !isValidMove(this.gs, r, c, 'black')) return;
const { x, y } = this.intToWorld(r, c);
const g = this.add.graphics().setPosition(x, y).setDepth(DEPTH.hint);
g.fillStyle(C.bFill, 0.38);
g.fillCircle(0, 0, SR);
this._ghostGfx = g;
}
// ── Move placement ────────────────────────────────────────────────────────────
onIntersectionClick(r, c) {
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'black') return;
if (!isValidMove(this.gs, r, c, 'black')) return;
if (this._ghostGfx) { this._ghostGfx.destroy(); this._ghostGfx = null; }
this.placeStone(r, c, 'black');
}
placeStone(r, c, color) {
this.animating = true;
this.updateTurnText();
// Identify captures before applying state (for animation)
const caps = findCaptures(this.gs.board, r, c, color);
const capturedKeys = new Set();
for (const g of caps) for (const k of g) capturedKeys.add(k);
// Update game state
this.gs = applyMove(this.gs, r, c);
// Animate capture removals (burst)
for (const key of capturedKeys) {
const [cr, cc] = key.split(',').map(Number);
const existing = this.stoneObjs[cr][cc];
if (existing) this.animateCaptureStone(existing, cr, cc);
}
// Place new stone with bounce
const { x, y } = this.intToWorld(r, c);
const g = this.add.graphics().setPosition(x, y).setDepth(DEPTH.stone);
this.drawStoneGraphics(g, color);
g.setScale(0.1);
this.stoneObjs[r][c] = g;
// Two-step spring bounce
this.tweens.add({
targets: g, scaleX: 1.18, scaleY: 1.18, duration: 80, ease: 'Quad.easeOut',
onComplete: () => {
this.tweens.add({
targets: g, scaleX: 1, scaleY: 1, duration: 60, ease: 'Quad.easeIn',
onComplete: () => {
playSound(this, SFX.PIECE_CLICK);
this.animating = false;
this.updateCaptureDisplay();
this.clearHints();
if (capturedKeys.size > 0) this.playOpponentEmotion(color === 'black' ? 'upset' : 'happy');
if (isGameOver(this.gs)) { this.onGameOver(); return; }
if (this.gs.turn === 'white') {
this.startAITurn();
}
// else: human gets another turn (opponent passed — handled via applyPass path)
},
});
},
});
}
animateCaptureStone(gfx, r, c) {
this.stoneObjs[r][c] = null;
const baseDelay = 30 * (Math.abs(r - 4) + Math.abs(c - 4));
this.time.delayedCall(baseDelay, () => {
this.tweens.add({
targets: gfx,
scaleX: 1.6, scaleY: 1.6, alpha: 0,
duration: 260, ease: 'Back.easeIn',
onComplete: () => gfx.destroy(),
});
});
}
// ── Pass / Resign ─────────────────────────────────────────────────────────────
onPlayerPass() {
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'black') return;
this.showPassStamp('You passed');
this.gs = applyPass(this.gs);
this.updateCaptureDisplay();
if (isGameOver(this.gs)) { this.onGameOver(); return; }
this.startAITurn();
}
onPlayerResign() {
if (this.gs.phase !== 'playing') return;
this.gs.phase = 'game_over';
this.gs.winner = 'white';
this.onGameOver();
}
showPassStamp(text) {
const cx = BX + FULL / 2;
const stamp = this.add.text(cx, BY + FULL / 2, text, {
fontFamily: 'Righteous', fontSize: '52px',
color: '#c0392b', alpha: 0,
}).setOrigin(0.5).setDepth(DEPTH.banner).setAngle(-12);
this.tweens.add({
targets: stamp, alpha: 0.85, scaleX: 1, scaleY: 1, duration: 180,
onComplete: () => {
this.time.delayedCall(900, () => {
this.tweens.add({ targets: stamp, alpha: 0, duration: 280, onComplete: () => stamp.destroy() });
});
},
});
}
// ── AI turn ───────────────────────────────────────────────────────────────────
startAITurn() {
const name = this.opponents[0]?.name ?? 'Opponent';
this.showTurnBanner(`${name}'s Turn`);
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => this.aiStep());
}
aiStep() {
if (this.gs.phase !== 'playing' || this.gs.turn !== 'white') return;
const skill = this.opponents[0]?.skill ?? 3;
const move = chooseMove(this.gs, 'white', skill);
if (!move) {
// AI passes
this.showPassStamp(`${this.opponents[0]?.name ?? 'Opponent'} passed`);
this.gs = applyPass(this.gs);
this.updateCaptureDisplay();
if (isGameOver(this.gs)) { this.onGameOver(); return; }
this.showTurnBanner('Your Turn');
this.updateTurnText();
return;
}
// Identify captures for animation
const caps = findCaptures(this.gs.board, move.r, move.c, 'white');
const capturedKeys = new Set();
for (const g of caps) for (const k of g) capturedKeys.add(k);
this.gs = applyMove(this.gs, move.r, move.c);
// Animate capture removals
for (const key of capturedKeys) {
const [cr, cc] = key.split(',').map(Number);
const existing = this.stoneObjs[cr][cc];
if (existing) this.animateCaptureStone(existing, cr, cc);
}
// Place AI stone with bounce
const { x, y } = this.intToWorld(move.r, move.c);
const g = this.add.graphics().setPosition(x, y).setDepth(DEPTH.stone);
this.drawStoneGraphics(g, 'white');
g.setScale(0.1);
this.stoneObjs[move.r][move.c] = g;
this.tweens.add({
targets: g, scaleX: 1.18, scaleY: 1.18, duration: 80, ease: 'Quad.easeOut',
onComplete: () => {
this.tweens.add({
targets: g, scaleX: 1, scaleY: 1, duration: 60, ease: 'Quad.easeIn',
onComplete: () => {
playSound(this, SFX.PIECE_CLICK);
this.updateCaptureDisplay();
if (capturedKeys.size > 0) this.playOpponentEmotion('happy');
if (isGameOver(this.gs)) { this.onGameOver(); return; }
this.showTurnBanner('Your Turn');
this.updateTurnText();
},
});
},
});
}
// ── Banners ───────────────────────────────────────────────────────────────────
showTurnBanner(text) {
const cx = BX + FULL / 2;
const banner = this.add.text(cx, BY - 70, text, {
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
backgroundColor: '#111923ee', padding: { x: 28, y: 12 },
}).setOrigin(0.5).setDepth(DEPTH.banner);
this.tweens.add({
targets: banner, y: BY - 14, duration: 320, ease: 'Back.easeOut',
onComplete: () => {
this.time.delayedCall(1100, () => {
this.tweens.add({ targets: banner, y: BY - 70, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
});
},
});
}
// ── Game over + territory reveal ──────────────────────────────────────────────
onGameOver() {
if (this.passBtn) this.passBtn.setEnabled(false);
const winner = getWinner(this.gs);
const isHuman = winner === 'black';
const name = this.opponents[0]?.name ?? 'Opponent';
this.playOpponentEmotion(isHuman ? 'upset' : 'happy');
this.recordResult(isHuman ? 'win' : 'loss');
const final = getFinalScore(this.gs);
// Territory reveal animation, then show result panel
this.time.delayedCall(300, () => {
this.animateTerritoryReveal(() => {
this.time.delayedCall(500, () => this.showEndPanel(winner, final));
});
});
if (isHuman) {
const cx = GX + (SIZE - 1) * SQ / 2;
const cy = GY + (SIZE - 1) * SQ / 2;
const emitter = this.add.particles(cx, cy, 'goParticle', {
speed: { min: 120, max: 450 }, lifespan: 1400,
scale: { start: 1.4, end: 0 }, alpha: { start: 1, end: 0 },
quantity: 4, frequency: 30, angle: { min: 0, max: 360 },
tint: [C.selGold, 0xffffff, COLORS.accent],
}).setDepth(DEPTH.banner);
this.time.delayedCall(2000, () => emitter.destroy());
}
}
animateTerritoryReveal(onDone) {
const board = this.gs.board;
const visited = Array.from({ length: SIZE }, () => Array(SIZE).fill(false));
const regions = [];
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (board[r][c] !== null || visited[r][c]) continue;
const region = [];
const stack = [[r, c]];
let touchesB = false, touchesW = false;
while (stack.length) {
const [cr, cc] = stack.pop();
if (cr < 0 || cr >= SIZE || cc < 0 || cc >= SIZE || visited[cr][cc]) continue;
visited[cr][cc] = true;
if (board[cr][cc] === 'black') { touchesB = true; continue; }
if (board[cr][cc] === 'white') { touchesW = true; continue; }
region.push([cr, cc]);
stack.push([cr-1,cc],[cr+1,cc],[cr,cc-1],[cr,cc+1]);
}
let owner = null;
if (touchesB && !touchesW) owner = 'black';
else if (touchesW && !touchesB) owner = 'white';
if (owner) regions.push({ owner, cells: region });
}
}
if (regions.length === 0) { onDone(); return; }
// Flatten and sort left-to-right for the sweep animation
const allCells = regions.flatMap(({ owner, cells }) => cells.map(([r, c]) => ({ r, c, owner })));
allCells.sort((a, b) => a.c !== b.c ? a.c - b.c : a.r - b.r);
let revealed = 0;
for (let i = 0; i < allCells.length; i++) {
const { r, c, owner } = allCells[i];
const { x, y } = this.intToWorld(r, c);
const gfx = this.add.graphics().setDepth(DEPTH.territory);
gfx.fillStyle(owner === 'black' ? C.bFill : C.wFill, 0.0);
gfx.fillCircle(x, y, SR * 0.65);
this.terrObjs.push(gfx);
this.time.delayedCall(i * 18, () => {
this.tweens.add({
targets: gfx, alpha: owner === 'black' ? 0.6 : 0.5,
duration: 220, ease: 'Quad.easeOut',
onComplete: () => {
revealed++;
if (revealed === allCells.length) onDone();
},
});
// Recolor the graphics properly
gfx.clear();
gfx.fillStyle(owner === 'black' ? C.bFill : C.wFill, 1);
gfx.fillCircle(x, y, SR * 0.65);
gfx.setAlpha(0);
});
}
}
showEndPanel(winner, final) {
const isHuman = winner === 'black';
const name = this.opponents[0]?.name ?? 'Opponent';
const cx = GX + (SIZE - 1) * SQ / 2, cy = GY + (SIZE - 1) * SQ / 2;
const msg = isHuman
? `🎉 You win!\nBlack: ${final.black.toFixed(1)} · White: ${final.white.toFixed(1)}\n(incl. ${KOMI} komi)`
: `${name} wins!\nBlack: ${final.black.toFixed(1)} · White: ${final.white.toFixed(1)}\n(incl. ${KOMI} komi)`;
const overlay = this.add.rectangle(cx, cy, 780, 320, 0x0a0e14, 0.92)
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
const txt = this.add.text(cx, cy - 50, msg, {
fontFamily: '"Julius Sans One"', fontSize: '28px',
color: isHuman ? '#ffd700' : COLORS.textHex, align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
new Button(this, cx - 95, cy + 100, 'Play Again', () => {
overlay.destroy(); txt.destroy(); this.initGame();
}, { width: 170, fontSize: 22 }).setDepth(DEPTH.banner + 1);
new Button(this, cx + 95, cy + 100, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 170, fontSize: 22 }).setDepth(DEPTH.banner + 1);
}
async recordResult(result) {
try {
const final = getFinalScore(this.gs);
const score = result === 'win' ? Math.round(final.black) : 0;
await api.post('/history/single-player', { slug: 'go', score, opponentScores: [], result });
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,229 @@
// Pure game logic for Go (9×9, Japanese rules).
// Board: 9×9 array, each cell = null | 'black' | 'white'.
// Stones placed on intersections. Black moves first (human).
export const SIZE = 9;
export const KOMI = 6.5; // white compensation for moving second
const ADJ = [[-1,0],[1,0],[0,-1],[0,1]];
function inBounds(r, c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
function other(color) { return color === 'black' ? 'white' : 'black'; }
export function cloneState(state) {
return {
board: state.board.map(row => [...row]),
turn: state.turn,
phase: state.phase,
winner: state.winner,
captures: { ...state.captures },
consecutivePasses: state.consecutivePasses,
koPoint: state.koPoint ? [...state.koPoint] : null,
prevHash: state.prevHash,
};
}
export function createInitialState() {
return {
board: Array.from({ length: SIZE }, () => Array(SIZE).fill(null)),
turn: 'black',
phase: 'playing',
winner: null,
captures: { black: 0, white: 0 }, // stones captured BY each color
consecutivePasses: 0,
koPoint: null,
prevHash: null,
};
}
// Returns the Set of "r,c" keys forming the connected group containing (r,c).
export function getGroup(board, r, c) {
const color = board[r][c];
if (!color) return new Set();
const group = new Set();
const stack = [[r, c]];
while (stack.length) {
const [cr, cc] = stack.pop();
const key = `${cr},${cc}`;
if (group.has(key)) continue;
if (board[cr][cc] !== color) continue;
group.add(key);
for (const [dr, dc] of ADJ) {
const nr = cr + dr, nc = cc + dc;
if (inBounds(nr, nc) && !group.has(`${nr},${nc}`)) stack.push([nr, nc]);
}
}
return group;
}
// Count liberties (adjacent empty intersections) of a group (Set of "r,c" keys).
export function getLiberties(board, group) {
const libs = new Set();
for (const key of group) {
const [r, c] = key.split(',').map(Number);
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (inBounds(nr, nc) && board[nr][nc] === null) libs.add(`${nr},${nc}`);
}
}
return libs.size;
}
// Returns array of groups (as Set<key>) of `color` that would be captured
// after placing a stone of `stoneColor` at (r, c).
export function findCaptures(board, r, c, stoneColor) {
const opp = other(stoneColor);
const checked = new Set();
const captured = [];
for (const [dr, dc] of ADJ) {
const nr = r + dr, nc = c + dc;
if (!inBounds(nr, nc) || board[nr][nc] !== opp) continue;
const key = `${nr},${nc}`;
if (checked.has(key)) continue;
const group = getGroup(board, nr, nc);
for (const k of group) checked.add(k);
// Count liberties excluding (r, c) since we're placing there
let libs = 0;
for (const k of group) {
const [gr, gc] = k.split(',').map(Number);
for (const [dr2, dc2] of ADJ) {
const lr = gr + dr2, lc = gc + dc2;
if (!inBounds(lr, lc)) continue;
if (board[lr][lc] === null && !(lr === r && lc === c)) libs++;
// deduplicate
}
}
// Recount properly
const libSet = new Set();
for (const k of group) {
const [gr, gc] = k.split(',').map(Number);
for (const [dr2, dc2] of ADJ) {
const lr = gr + dr2, lc = gc + dc2;
if (inBounds(lr, lc) && board[lr][lc] === null && !(lr === r && lc === c)) {
libSet.add(`${lr},${lc}`);
}
}
}
if (libSet.size === 0) captured.push(group);
}
return captured;
}
export function isValidMove(state, r, c, color) {
if (state.phase === 'game_over') return false;
if (state.turn !== color) return false;
if (!inBounds(r, c)) return false;
if (state.board[r][c] !== null) return false;
// Ko: forbidden point
if (state.koPoint && state.koPoint[0] === r && state.koPoint[1] === c) return false;
// Suicide check: after placing, does own group have liberties?
const testBoard = state.board.map(row => [...row]);
testBoard[r][c] = color;
// First remove any captured opponent groups (they free up liberties)
const caps = findCaptures(state.board, r, c, color);
for (const g of caps) for (const k of g) { const [gr, gc] = k.split(',').map(Number); testBoard[gr][gc] = null; }
const ownGroup = getGroup(testBoard, r, c);
if (getLiberties(testBoard, ownGroup) === 0) return false;
return true;
}
export function boardHash(board) {
return board.map(row => row.map(c => c === 'black' ? 'B' : c === 'white' ? 'W' : '.').join('')).join('|');
}
export function applyMove(state, r, c) {
const s = cloneState(state);
const color = s.turn;
s.prevHash = boardHash(s.board);
s.board[r][c] = color;
s.consecutivePasses = 0;
// Capture opponent groups
const caps = findCaptures(state.board, r, c, color);
let captureCount = 0;
let singleCapturePos = null;
for (const g of caps) {
captureCount += g.size;
if (g.size === 1) singleCapturePos = [...g][0].split(',').map(Number);
for (const k of g) { const [gr, gc] = k.split(',').map(Number); s.board[gr][gc] = null; }
}
s.captures[color] += captureCount;
// Ko: if exactly one stone was captured and the placement was a single-stone group, set ko point
const ownGroupAfter = getGroup(s.board, r, c);
if (captureCount === 1 && ownGroupAfter.size === 1 && singleCapturePos) {
s.koPoint = singleCapturePos;
} else {
s.koPoint = null;
}
s.turn = other(color);
return s;
}
export function applyPass(state) {
const s = cloneState(state);
s.consecutivePasses += 1;
s.koPoint = null;
if (s.consecutivePasses >= 2) {
s.phase = 'game_over';
const final = getFinalScore(s);
s.winner = final.black > final.white ? 'black' : 'white';
} else {
s.turn = other(s.turn);
}
return s;
}
export function isGameOver(state) { return state.phase === 'game_over'; }
// Territory scoring: flood-fill empty regions; if only one color borders → territory.
export function scoreTerritory(state) {
const board = state.board;
const visited = Array.from({ length: SIZE }, () => Array(SIZE).fill(false));
let black = 0, white = 0;
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (board[r][c] !== null || visited[r][c]) continue;
// BFS the empty region
const region = [];
const stack = [[r, c]];
let touchesBlack = false, touchesWhite = false;
while (stack.length) {
const [cr, cc] = stack.pop();
if (!inBounds(cr, cc) || visited[cr][cc]) continue;
visited[cr][cc] = true;
if (board[cr][cc] === 'black') { touchesBlack = true; continue; }
if (board[cr][cc] === 'white') { touchesWhite = true; continue; }
region.push([cr, cc]);
for (const [dr, dc] of ADJ) stack.push([cr + dr, cc + dc]);
}
if (touchesBlack && !touchesWhite) black += region.length;
else if (touchesWhite && !touchesBlack) white += region.length;
}
}
return { black, white };
}
export function getFinalScore(state) {
const terr = scoreTerritory(state);
return {
black: terr.black + state.captures.black,
white: terr.white + state.captures.white + KOMI,
};
}
export function getWinner(state) { return state.winner; }
// Returns all valid placement positions for `color`.
export function getValidMoves(state, color) {
if (state.phase === 'game_over') return [];
const moves = [];
for (let r = 0; r < SIZE; r++)
for (let c = 0; c < SIZE; c++)
if (isValidMove(state, r, c, color)) moves.push({ r, c });
return moves;
}

View File

@ -0,0 +1,117 @@
// Othello AI — alpha-beta minimax with classic positional heuristics.
import { other, getValidMoves, getFlips, getScore, getWinner, cloneState, applyMove, mustPass, SIZE } from './OthelloLogic.js';
const SKILL_PROFILES = {
1: { depth: 1, blunder: 0.50, noise: 100, delay: [900, 1500] },
2: { depth: 2, blunder: 0.30, noise: 60, delay: [800, 1200] },
3: { depth: 3, blunder: 0.12, noise: 25, delay: [650, 1050] },
4: { depth: 4, blunder: 0.04, noise: 10, delay: [500, 900] },
5: { depth: 5, blunder: 0.00, noise: 0, delay: [400, 800] },
};
// Classic positional weight table (corners are very valuable).
const W = [
[120, -20, 20, 5, 5, 20, -20, 120],
[-20, -40, -5, -5, -5, -5, -40, -20],
[ 20, -5, 15, 3, 3, 15, -5, 20],
[ 5, -5, 3, 3, 3, 3, -5, 5],
[ 5, -5, 3, 3, 3, 3, -5, 5],
[ 20, -5, 15, 3, 3, 15, -5, 20],
[-20, -40, -5, -5, -5, -5, -40, -20],
[120, -20, 20, 5, 5, 20, -20, 120],
];
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 evaluate(state, aiColor) {
if (state.phase === 'game_over') {
const w = getWinner(state);
if (w === aiColor) return 100000;
if (w === 'draw') return 0;
return -100000;
}
const human = other(aiColor);
const board = state.board;
// Positional score
let posScore = 0;
for (let r = 0; r < SIZE; r++)
for (let c = 0; c < SIZE; c++) {
if (board[r][c] === aiColor) posScore += W[r][c];
else if (board[r][c] === human) posScore -= W[r][c];
}
// Mobility: difference in valid move counts
const aiMoves = getValidMoves(state, aiColor).length;
const humanMoves = getValidMoves(state, human).length;
const mobility = aiMoves - humanMoves;
// Disc count matters more in the endgame
const { black, white } = getScore(state);
const filled = black + white;
const discDiff = (aiColor === 'black' ? black - white : white - black);
const discWeight = filled > 52 ? 3 : 0;
return posScore * 10 + mobility * 5 + discDiff * discWeight;
}
function search(state, depth, alpha, beta, aiColor) {
if (state.phase === 'game_over' || depth <= 0) return evaluate(state, aiColor);
const moves = getValidMoves(state, state.turn);
if (moves.length === 0) {
// Current player passes; check if game ends or opponent goes.
const passState = cloneState(state);
passState.turn = other(state.turn);
const oppMoves = getValidMoves(passState, passState.turn);
if (oppMoves.length === 0) {
passState.phase = 'game_over';
passState.winner = (() => { const s = getScore(passState); return s.black > s.white ? 'black' : s.white > s.black ? 'white' : 'draw'; })();
return evaluate(passState, aiColor);
}
return search(passState, depth, alpha, beta, aiColor);
}
if (state.turn === aiColor) {
let value = -Infinity;
for (const m of moves) {
value = Math.max(value, search(applyMove(state, m), depth - 1, alpha, beta, aiColor));
alpha = Math.max(alpha, value);
if (alpha >= beta) break;
}
return value;
}
let value = Infinity;
for (const m of moves) {
value = Math.min(value, search(applyMove(state, m), depth - 1, alpha, beta, aiColor));
beta = Math.min(beta, value);
if (beta <= alpha) break;
}
return value;
}
export function chooseMove(state, aiColor, skill) {
const prof = profileFor(skill);
const moves = getValidMoves(state, aiColor);
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)];
let best = null, bestScore = -Infinity;
for (const m of moves) {
const ns = applyMove(state, m);
let val = search(ns, prof.depth - 1, -Infinity, Infinity, aiColor);
val += (Math.random() * 2 - 1) * prof.noise;
if (val > bestScore) { bestScore = val; best = m; }
}
return best ?? moves[0];
}

View File

@ -0,0 +1,504 @@
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 {
SIZE, other, createInitialState, getValidMoves, getFlips, applyMove,
mustPass, isGameOver, getScore, getWinner,
} from './OthelloLogic.js';
import { chooseMove, nextThinkDelay } from './OthelloAI.js';
// ── Layout ─────────────────────────────────────────────────────────────────────
const SQ = 104;
const BOARD = SQ * SIZE; // 832
const BX = Math.round(GAME_WIDTH / 2 - BOARD / 2); // 544
const BY = Math.round(GAME_HEIGHT / 2 - BOARD / 2); // 124
const FRAME = 30;
const CR = Math.round(SQ * 0.40); // disc radius ≈42
const DEPTH = { board: 0, piece: 10, hint: 8, overlay: 20, moving: 30, ui: 50, banner: 60 };
// ── Colors ─────────────────────────────────────────────────────────────────────
const C = {
felt: 0x1e6b3e, // classic Othello board green
feltDk: 0x185930, // grid lines
frame: 0x3a2414,
frameLt: 0x6b4423,
frameLn: 0x8b5c2a,
bFill: 0x1c1a26,
bRing: 0x3a3848,
bEdge: 0x0d0c14,
wFill: 0xf4f0e0,
wRing: 0xfffaf0,
wEdge: 0xc8b894,
hint: 0x80ff80,
selGold: 0xffd700,
};
export default class OthelloGame extends Phaser.Scene {
constructor() { super('OthelloGame'); }
init(data) {
this._initData = { ...data };
this.gameDef = data.game;
this.opponents = data.opponents ?? [];
this.playfield = data.playfield ?? null;
this.gs = null;
this.animating = false;
this.discObjs = []; // discObjs[r][c] = Graphics | null
this.hintObjs = []; // hint dot graphics + zones
this.overlayObjs = [];
this.opponentPortrait = null;
this.scoreBlackText = null;
this.scoreWhiteText = null;
this.turnText = null;
}
create() {
new MusicPlayer(this, this.cache.json.get('music').tracks);
this.buildParticleTexture();
this.buildPlayfield();
this.buildBoard();
this.buildUI();
this.buildPlayerCards();
this.initGame();
}
// ── Construction ──────────────────────────────────────────────────────────────
buildParticleTexture() {
const g = this.make.graphics({ x: 0, y: 0, add: false });
g.fillStyle(0xffffff, 1);
g.fillCircle(5, 5, 5);
g.generateTexture('othelloParticle', 10, 10);
g.destroy();
}
buildPlayfield() {
const pf = this.playfield;
if (pf?.key && this.textures.exists(pf.key)) {
this.add.image(GAME_WIDTH / 2, GAME_HEIGHT / 2, pf.key)
.setDisplaySize(GAME_WIDTH, GAME_HEIGHT).setDepth(DEPTH.board - 2);
} else if (pf?.fallbackColor) {
const color = parseInt(pf.fallbackColor.replace('#', ''), 16);
this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, color)
.setDepth(DEPTH.board - 2);
}
}
buildBoard() {
const g = this.add.graphics().setDepth(DEPTH.board);
// Wood frame
g.fillStyle(C.frame, 1);
g.fillRoundedRect(BX - FRAME, BY - FRAME, BOARD + FRAME * 2, BOARD + FRAME * 2, 12);
g.lineStyle(3, C.frameLt, 1);
g.strokeRoundedRect(BX - FRAME + 5, BY - FRAME + 5, BOARD + FRAME * 2 - 10, BOARD + FRAME * 2 - 10, 9);
g.lineStyle(1, C.frameLn, 0.6);
g.strokeRect(BX - 2, BY - 2, BOARD + 4, BOARD + 4);
// Felt background
g.fillStyle(C.felt, 1);
g.fillRect(BX, BY, BOARD, BOARD);
// Grid lines
g.lineStyle(1, C.feltDk, 0.7);
for (let i = 0; i <= SIZE; i++) {
g.lineBetween(BX + i * SQ, BY, BX + i * SQ, BY + BOARD);
g.lineBetween(BX, BY + i * SQ, BX + BOARD, BY + i * SQ);
}
// Star dots at the four quadrant corners (traditional Othello marks)
g.fillStyle(C.feltDk, 0.9);
for (const [r, c] of [[2,2],[2,5],[5,2],[5,5]]) {
const { x, y } = this.sqToWorld(r, c);
g.fillCircle(x, y, 6);
}
// Subtle center lines marking the 2×2 starting area
g.lineStyle(1, 0xffffff, 0.12);
const mid = BX + BOARD / 2;
g.lineBetween(mid, BY, mid, BY + BOARD);
g.lineBetween(BX, BY + BOARD / 2, BX + BOARD, BY + BOARD / 2);
// Coordinate labels
for (let c = 0; c < SIZE; c++) {
this.add.text(BX + c * SQ + SQ / 2, BY + BOARD + 16, String.fromCharCode(65 + c), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
for (let r = 0; r < SIZE; r++) {
this.add.text(BX - 16, BY + r * SQ + SQ / 2, String(SIZE - r), {
fontFamily: '"Julius Sans One"', fontSize: '18px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.board);
}
}
buildUI() {
const cx = BX + BOARD / 2;
this.turnText = this.add.text(cx, BY + BOARD + 52, '', {
fontFamily: '"Julius Sans One"', fontSize: '24px', color: COLORS.mutedHex,
}).setOrigin(0.5).setDepth(DEPTH.ui);
new Button(this, BX + BOARD + FRAME + 90, BY + 60, 'Leave',
() => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 }).setDepth(DEPTH.ui);
new Button(this, BX + BOARD + FRAME + 90, BY + 124, 'New',
() => this.initGame(),
{ variant: 'ghost', width: 150, height: 46, fontSize: 20 }).setDepth(DEPTH.ui);
}
buildPlayerCards() {
const opp = this.opponents[0];
const r = 78;
const depth = DEPTH.ui;
const avatarX = BX / 2;
// Opponent (white discs) — top card
const oppAY = BY + r + 20;
this.add.circle(avatarX, oppAY, r + 5, C.frame).setDepth(depth);
this.opponentPortrait = createOpponentPortrait(this, opp, avatarX, oppAY, r, depth + 1);
this.add.text(avatarX, oppAY + r + 14, opp?.name ?? 'CPU', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 0).setDepth(depth + 2);
this.scoreWhiteText = this.add.text(avatarX, oppAY + r + 56, '2', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(depth + 2);
// Player (black discs) — bottom card
const plrAY = BY + BOARD - r - 20;
this.add.circle(avatarX, plrAY, r + 5, COLORS.accent, 0.5).setDepth(depth);
createPlayerPortrait(this, avatarX, plrAY, r, depth + 1, 'Othello');
this.add.text(avatarX, plrAY - r - 14, auth.user?.username ?? 'You', {
fontFamily: '"Julius Sans One"', fontSize: '18px',
color: COLORS.textHex, wordWrap: { width: 230 }, align: 'center',
}).setOrigin(0.5, 1).setDepth(depth + 2);
this.scoreBlackText = this.add.text(avatarX, plrAY - r - 56, '2', {
fontFamily: '"Julius Sans One"', fontSize: '26px', color: COLORS.textHex,
}).setOrigin(0.5).setDepth(depth + 2);
}
playOpponentEmotion(emotion) { this.opponentPortrait?.playEmotion(emotion); }
// ── Game flow ─────────────────────────────────────────────────────────────────
initGame() {
this.clearDiscs();
this.clearHints();
this.clearOverlays();
this.animating = false;
this.gs = createInitialState();
this.renderAll();
this.showTurnBanner('Your Turn — Black');
}
renderAll() {
this.clearDiscs();
this.clearHints();
this.renderDiscs();
this.updateScore();
this.updateTurnText();
if (this.gs.phase === 'playing' && this.gs.turn === 'black' && !this.animating) {
this.showValidHints();
}
}
updateTurnText() {
if (!this.turnText) return;
if (this.gs.phase === 'game_over') { this.turnText.setText(''); return; }
this.turnText.setText(this.gs.turn === 'black' ? 'Your move' : 'Opponent thinking…');
}
updateScore() {
const { black, white } = getScore(this.gs);
this.scoreBlackText?.setText(String(black));
this.scoreWhiteText?.setText(String(white));
}
// ── Disc rendering ────────────────────────────────────────────────────────────
sqToWorld(r, c) { return { x: BX + c * SQ + SQ / 2, y: BY + r * SQ + SQ / 2 }; }
clearDiscs() {
if (this.discObjs.length) {
for (const row of this.discObjs)
for (const g of row) { if (g) { this.tweens.killTweensOf(g); g.destroy(); } }
}
this.discObjs = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
}
renderDiscs() {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const cell = this.gs.board[r][c];
if (!cell) continue;
const { x, y } = this.sqToWorld(r, c);
const g = this.add.graphics().setPosition(x, y).setDepth(DEPTH.piece);
this.drawDiscGraphics(g, cell);
this.discObjs[r][c] = g;
}
}
}
drawDiscGraphics(g, color) {
g.clear();
const fill = color === 'black' ? C.bFill : C.wFill;
const ring = color === 'black' ? C.bRing : C.wRing;
const edge = color === 'black' ? C.bEdge : C.wEdge;
const hi = color === 'black' ? 0x5a5870 : 0xffffff;
g.fillStyle(0x000000, 0.30);
g.fillCircle(3, 5, CR);
g.fillStyle(edge, 1);
g.fillCircle(0, 0, CR);
g.fillStyle(ring, 1);
g.fillCircle(0, 0, CR - 3);
g.fillStyle(fill, 1);
g.fillCircle(0, 0, CR - 8);
g.lineStyle(2, edge, 0.4);
g.strokeCircle(0, 0, CR - 14);
// Top-left sheen arc
g.lineStyle(3, hi, 0.55);
g.beginPath();
g.arc(0, 0, CR - 12, Phaser.Math.DegToRad(200), Phaser.Math.DegToRad(320));
g.strokePath();
}
// ── Valid move hints ──────────────────────────────────────────────────────────
clearHints() {
for (const o of this.hintObjs) o.destroy();
this.hintObjs = [];
}
clearOverlays() {
for (const o of this.overlayObjs) o.destroy();
this.overlayObjs = [];
}
showValidHints() {
const moves = getValidMoves(this.gs, 'black');
for (const { r, c } of moves) {
const { x, y } = this.sqToWorld(r, c);
const dot = this.add.graphics().setDepth(DEPTH.hint);
dot.fillStyle(C.hint, 0.55);
dot.fillCircle(x, y, 16);
dot.lineStyle(2, 0xffffff, 0.35);
dot.strokeCircle(x, y, 16);
this.tweens.add({ targets: dot, alpha: { from: 0.55, to: 0.15 }, duration: 700, yoyo: true, repeat: -1 });
const zone = this.add.zone(x, y, SQ, SQ).setInteractive({ useHandCursor: true }).setDepth(DEPTH.hint + 1);
zone.on('pointerdown', () => this.onSquareClick(r, c));
this.hintObjs.push(dot, zone);
}
}
// ── Move execution ────────────────────────────────────────────────────────────
onSquareClick(r, c) {
if (this.animating || this.gs.phase !== 'playing' || this.gs.turn !== 'black') return;
const flips = getFlips(this.gs.board, r, c, 'black');
if (flips.length === 0) return;
this.clearHints();
this.executeMove(r, c, 'black', flips);
}
executeMove(r, c, color, flips) {
this.animating = true;
this.updateTurnText();
// Place new disc with spring scale-in
const { x, y } = this.sqToWorld(r, c);
const g = this.add.graphics().setPosition(x, y).setDepth(DEPTH.moving);
this.drawDiscGraphics(g, color);
g.setScale(0);
this.discObjs[r][c] = g;
this.tweens.add({
targets: g, scaleX: 1.18, scaleY: 1.18, duration: 90, ease: 'Quad.easeOut',
onComplete: () => {
this.tweens.add({ targets: g, scaleX: 1, scaleY: 1, duration: 70, ease: 'Quad.easeIn' });
},
});
playSound(this, SFX.PIECE_CLICK);
// Cascade flip animation, then update state
this.animateFlips(r, c, flips, color, () => {
this.gs = applyMove(this.gs, { r, c });
this.animating = false;
if (flips.length > 0) {
this.playOpponentEmotion(color === 'black' ? 'upset' : 'happy');
}
this.renderAll();
if (isGameOver(this.gs)) { this.onGameOver(); return; }
if (color === 'black' && this.gs.turn === 'black') {
// White AI has no valid moves — skip it, human goes again
const name = this.opponents[0]?.name ?? 'Opponent';
this.showTurnBanner(`${name} has no moves — passing`);
// renderAll() already showed hints for black
} else if (color === 'white' && this.gs.turn === 'white') {
// Human has no valid moves — skip it, AI goes again
this.showTurnBanner('You have no valid moves — passing');
this.time.delayedCall(1200, () => this.startAITurn());
} else if (this.gs.turn === 'white') {
this.startAITurn();
}
});
}
// ── Flip cascade animation ────────────────────────────────────────────────────
animateFlips(placedR, placedC, flips, newColor, onAllDone) {
if (flips.length === 0) { this.time.delayedCall(90, onAllDone); return; }
// Group flips by Manhattan distance (ripple outward from placed disc)
const byDist = new Map();
for (const f of flips) {
const d = Math.abs(f.r - placedR) + Math.abs(f.c - placedC);
if (!byDist.has(d)) byDist.set(d, []);
byDist.get(d).push(f);
}
const distances = [...byDist.keys()].sort((a, b) => a - b);
let groupsDone = 0;
distances.forEach((dist, idx) => {
const group = byDist.get(dist);
const delay = 90 + idx * 55; // stagger by distance rank
let discsDone = 0;
for (const f of group) {
this.flipDisc(f.r, f.c, newColor, delay, () => {
discsDone++;
if (discsDone === group.length) {
groupsDone++;
if (groupsDone === distances.length) onAllDone();
}
});
}
});
}
flipDisc(r, c, newColor, delay, onDone) {
const g = this.discObjs[r][c];
if (!g) { onDone?.(); return; }
this.time.delayedCall(delay, () => {
// Squeeze to flat (scaleX → 0)
this.tweens.add({
targets: g, scaleX: 0, duration: 110, ease: 'Quad.easeIn',
onComplete: () => {
this.drawDiscGraphics(g, newColor); // swap color at the midpoint
// Expand back
this.tweens.add({
targets: g, scaleX: 1, duration: 110, ease: 'Quad.easeOut',
onComplete: onDone,
});
},
});
});
}
// ── AI turn ───────────────────────────────────────────────────────────────────
startAITurn() {
const name = this.opponents[0]?.name ?? 'Opponent';
this.showTurnBanner(`${name}'s Turn`);
this.time.delayedCall(nextThinkDelay(this.opponents[0]?.skill ?? 3), () => this.aiStep());
}
aiStep() {
if (this.gs.phase !== 'playing' || this.gs.turn !== 'white') return;
const skill = this.opponents[0]?.skill ?? 3;
const move = chooseMove(this.gs, 'white', skill);
if (!move) return; // applyMove already handled any pass; shouldn't reach here
const flips = getFlips(this.gs.board, move.r, move.c, 'white');
this.executeMove(move.r, move.c, 'white', flips);
}
// ── Banners ───────────────────────────────────────────────────────────────────
showTurnBanner(text) {
const cx = BX + BOARD / 2;
const banner = this.add.text(cx, BY - 70, text, {
fontFamily: 'Righteous', fontSize: '36px', color: COLORS.textHex,
backgroundColor: '#111923ee', padding: { x: 28, y: 12 },
}).setOrigin(0.5).setDepth(DEPTH.banner);
this.tweens.add({
targets: banner, y: BY - 14, duration: 320, ease: 'Back.easeOut',
onComplete: () => {
this.time.delayedCall(1200, () => {
this.tweens.add({ targets: banner, y: BY - 70, alpha: 0, duration: 220, onComplete: () => banner.destroy() });
});
},
});
}
// ── Game over ─────────────────────────────────────────────────────────────────
onGameOver() {
const winner = getWinner(this.gs);
const isHuman = winner === 'black';
const isDraw = winner === 'draw';
const name = this.opponents[0]?.name ?? 'Opponent';
const { black, white } = getScore(this.gs);
this.playOpponentEmotion(isHuman ? 'upset' : isDraw ? 'happy' : 'happy');
this.recordResult(isHuman ? 'win' : isDraw ? 'draw' : 'loss');
const cx = BX + BOARD / 2, cy = BY + BOARD / 2;
if (isHuman) {
const emitter = this.add.particles(cx, cy, 'othelloParticle', {
speed: { min: 150, max: 500 }, lifespan: 1400,
scale: { start: 1.5, end: 0 }, alpha: { start: 1, end: 0 },
quantity: 5, frequency: 25, angle: { min: 0, max: 360 },
tint: [C.selGold, 0xffffff, COLORS.accent],
}).setDepth(DEPTH.banner);
this.time.delayedCall(2000, () => emitter.destroy());
}
this.time.delayedCall(450, () => {
const msg = isDraw
? `It's a draw!\nBlack ${black} · White ${white}`
: isHuman
? `🎉 You win!\nBlack ${black} · White ${white}`
: `${name} wins!\nBlack ${black} · White ${white}`;
const overlay = this.add.rectangle(cx, cy, 760, 310, 0x0a0e14, 0.9)
.setStrokeStyle(3, COLORS.accent).setDepth(DEPTH.banner);
const txt = this.add.text(cx, cy - 44, msg, {
fontFamily: '"Julius Sans One"', fontSize: '30px',
color: isHuman ? '#ffd700' : isDraw ? COLORS.textHex : COLORS.textHex,
align: 'center',
}).setOrigin(0.5).setDepth(DEPTH.banner + 1);
new Button(this, cx - 90, cy + 88, 'Play Again', () => {
overlay.destroy(); txt.destroy(); this.initGame();
}, { width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
new Button(this, cx + 90, cy + 88, 'Leave', () => this.scene.start('GameMenu'),
{ variant: 'ghost', width: 160, fontSize: 22 }).setDepth(DEPTH.banner + 1);
});
}
async recordResult(result) {
try {
const { black, white } = getScore(this.gs);
const score = result === 'win' ? black * 2 : result === 'draw' ? 10 : 0;
await api.post('/history/single-player', { slug: 'othello', score, opponentScores: [], result });
} catch { /* best effort */ }
}
}

View File

@ -0,0 +1,115 @@
// Pure game logic for Othello (Reversi).
// Board: 8×8 array, each cell = null | 'black' | 'white'.
// Black moves first (human). White is the AI.
export const SIZE = 8;
const DIRS = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
export function other(color) { return color === 'black' ? 'white' : 'black'; }
function inBounds(r, c) { return r >= 0 && r < SIZE && c >= 0 && c < SIZE; }
export function cloneState(state) {
return {
board: state.board.map(row => [...row]),
turn: state.turn,
phase: state.phase,
winner: state.winner,
};
}
export function createInitialState() {
const board = Array.from({ length: SIZE }, () => Array(SIZE).fill(null));
const m = SIZE / 2;
board[m-1][m-1] = 'white';
board[m-1][m] = 'black';
board[m][m-1] = 'black';
board[m][m] = 'white';
return { board, turn: 'black', phase: 'playing', winner: null };
}
// Returns all disc positions [{r,c}] that would flip if `color` placed at (r,c).
export function getFlips(board, r, c, color) {
if (board[r][c] !== null) return [];
const opp = other(color);
const flips = [];
for (const [dr, dc] of DIRS) {
const line = [];
let nr = r + dr, nc = c + dc;
while (inBounds(nr, nc) && board[nr][nc] === opp) {
line.push({ r: nr, c: nc });
nr += dr; nc += dc;
}
if (line.length > 0 && inBounds(nr, nc) && board[nr][nc] === color) {
flips.push(...line);
}
}
return flips;
}
// Returns all valid placement positions [{r,c}] for `color`.
export function getValidMoves(state, color) {
if (state.phase === 'game_over') return [];
const moves = [];
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
if (state.board[r][c] !== null) continue;
if (getFlips(state.board, r, c, color).length > 0) moves.push({ r, c });
}
}
return moves;
}
export function mustPass(state, color) {
return state.phase !== 'game_over' && getValidMoves(state, color).length === 0;
}
export function applyMove(state, move) {
const s = cloneState(state);
const { r, c } = move;
const color = s.turn;
// Compute flips on the board before placing, then place + flip.
const flips = getFlips(s.board, r, c, color);
s.board[r][c] = color;
for (const f of flips) s.board[f.r][f.c] = color;
const next = other(color);
const nextHasMoves = getValidMoves(s, next).length > 0;
const selfHasMoves = getValidMoves(s, color).length > 0;
if (nextHasMoves) {
s.turn = next;
} else if (selfHasMoves) {
s.turn = color; // opponent must pass; same player continues
} else {
s.phase = 'game_over';
s.winner = _winner(s.board);
}
return s;
}
export function isGameOver(state) { return state.phase === 'game_over'; }
export function getScore(state) {
let black = 0, white = 0;
for (const row of state.board)
for (const cell of row) {
if (cell === 'black') black++;
else if (cell === 'white') white++;
}
return { black, white };
}
export function getWinner(state) {
if (state.phase !== 'game_over') return null;
return state.winner;
}
function _winner(board) {
let b = 0, w = 0;
for (const row of board) for (const c of row) { if (c === 'black') b++; else if (c === 'white') w++; }
if (b > w) return 'black';
if (w > b) return 'white';
return 'draw';
}

View File

@ -39,6 +39,8 @@ import WordLadderGame from './games/wordladder/WordLadderGame.js';
import WordSearchGame from './games/wordsearch/WordSearchGame.js';
import HangmanGame from './games/hangman/HangmanGame.js';
import SudokuGame from './games/sudoku/SudokuGame.js';
import OthelloGame from './games/othello/OthelloGame.js';
import GoGame from './games/go/GoGame.js';
const config = {
type: Phaser.AUTO,
@ -91,6 +93,8 @@ const config = {
WordSearchGame,
HangmanGame,
SudokuGame,
OthelloGame,
GoGame,
],
};

View File

@ -20,7 +20,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' };
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' };
if (slugDispatch[this.game.slug]) {
this.scene.start(slugDispatch[this.game.slug], {
game: this.game,

View File

@ -373,7 +373,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 15 AI skill.
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder'].includes(this.gameDef.slug)) {
if (['nerts', 'checkers', 'chess', 'wordle', 'scrabble', 'ghost', 'wordladder', 'othello', 'go'].includes(this.gameDef.slug)) {
bio.style.webkitLineClamp = '1';
const skillRow = document.createElement('div');

View File

@ -51,4 +51,6 @@ registerGame({ slug: 'ghost', name: 'Ghost', category: 'word', minPlayers: 2, ma
registerGame({ slug: 'wordladder', name: 'Word Ladder', category: 'word', minPlayers: 1, maxPlayers: 2, minOpponents: 0, maxOpponents: 1 });
registerGame({ slug: 'wordsearch', name: 'Word Search', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });
registerGame({ slug: 'hangman', name: 'Hangman', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });
registerGame({ slug: 'sudoku', name: 'Sudoku', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });
registerGame({ slug: 'sudoku', name: 'Sudoku', category: 'word', minPlayers: 1, maxPlayers: 1, minOpponents: 0, maxOpponents: 0 });
registerGame({ slug: 'othello', name: 'Othello', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
registerGame({ slug: 'go', name: 'Go', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });