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:
parent
8ccb100678
commit
618d3d31c4
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: A–I along bottom, 9–1 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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 1–5 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');
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue