init: board game phaser start
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tic-Tac-Toe - boardgame-phaser</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div id="ui-root"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "sample-game",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@preact/signals-core": "^1.5.1",
|
||||
"boardgame-core": "file:../../../boardgame-core",
|
||||
"boardgame-phaser": "workspace:*",
|
||||
"mutative": "^1.3.0",
|
||||
"phaser": "^3.80.1",
|
||||
"preact": "^10.19.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.8.1",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import {
|
||||
createGameCommandRegistry,
|
||||
type Part,
|
||||
createRegion,
|
||||
type MutableSignal,
|
||||
} from 'boardgame-core';
|
||||
|
||||
const BOARD_SIZE = 3;
|
||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||
const WINNING_LINES: number[][][] = [
|
||||
[[0, 0], [0, 1], [0, 2]],
|
||||
[[1, 0], [1, 1], [1, 2]],
|
||||
[[2, 0], [2, 1], [2, 2]],
|
||||
[[0, 0], [1, 0], [2, 0]],
|
||||
[[0, 1], [1, 1], [2, 1]],
|
||||
[[0, 2], [1, 2], [2, 2]],
|
||||
[[0, 0], [1, 1], [2, 2]],
|
||||
[[0, 2], [1, 1], [2, 0]],
|
||||
];
|
||||
|
||||
export type PlayerType = 'X' | 'O';
|
||||
export type WinnerType = PlayerType | 'draw' | null;
|
||||
|
||||
export type TicTacToePart = Part & { player: PlayerType };
|
||||
|
||||
export function createInitialState() {
|
||||
return {
|
||||
board: createRegion('board', [
|
||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
||||
]),
|
||||
parts: {} as Record<string, TicTacToePart>,
|
||||
currentPlayer: 'X' as PlayerType,
|
||||
winner: null as WinnerType,
|
||||
turn: 0,
|
||||
};
|
||||
}
|
||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||
|
||||
const registration = createGameCommandRegistry<TicTacToeState>();
|
||||
export const registry = registration.registry;
|
||||
|
||||
registration.add('setup', async function () {
|
||||
const { context } = this;
|
||||
while (true) {
|
||||
const currentPlayer = context.value.currentPlayer;
|
||||
const turnNumber = context.value.turn + 1;
|
||||
const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`);
|
||||
if (!turnOutput.success) throw new Error(turnOutput.error);
|
||||
|
||||
context.produce(state => {
|
||||
state.winner = turnOutput.result.winner;
|
||||
if (!state.winner) {
|
||||
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
||||
state.turn = turnNumber;
|
||||
}
|
||||
});
|
||||
if (context.value.winner) break;
|
||||
}
|
||||
|
||||
return context.value;
|
||||
});
|
||||
|
||||
registration.add('turn <player> <turn:number>', async function (cmd) {
|
||||
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
|
||||
|
||||
const playCmd = await this.prompt(
|
||||
'play <player> <row:number> <col:number>',
|
||||
(command) => {
|
||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
||||
|
||||
if (player !== turnPlayer) {
|
||||
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||
}
|
||||
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
|
||||
return `Invalid position: (${row}, ${col}).`;
|
||||
}
|
||||
if (isCellOccupied(this.context, row, col)) {
|
||||
return `Cell (${row}, ${col}) is already occupied.`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
);
|
||||
const [player, row, col] = playCmd.params as [PlayerType, number, number];
|
||||
|
||||
placePiece(this.context, row, col, turnPlayer);
|
||||
|
||||
const winner = checkWinner(this.context);
|
||||
if (winner) return { winner };
|
||||
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
||||
|
||||
return { winner: null };
|
||||
});
|
||||
|
||||
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
|
||||
const board = host.value.board;
|
||||
return board.partMap[`${row},${col}`] !== undefined;
|
||||
}
|
||||
|
||||
export function hasWinningLine(positions: number[][]): boolean {
|
||||
return WINNING_LINES.some(line =>
|
||||
line.every(([r, c]) =>
|
||||
positions.some(([pr, pc]) => pr === r && pc === c),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
|
||||
const parts = Object.values(host.value.parts);
|
||||
|
||||
const xPositions = parts.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
||||
const oPositions = parts.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
||||
|
||||
if (hasWinningLine(xPositions)) return 'X';
|
||||
if (hasWinningLine(oPositions)) return 'O';
|
||||
if (parts.length >= MAX_TURNS) return 'draw';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
|
||||
const board = host.value.board;
|
||||
const moveNumber = Object.keys(host.value.parts).length + 1;
|
||||
const piece: TicTacToePart = {
|
||||
id: `piece-${player}-${moveNumber}`,
|
||||
regionId: 'board',
|
||||
position: [row, col],
|
||||
player,
|
||||
};
|
||||
host.produce(state => {
|
||||
state.parts[piece.id] = piece;
|
||||
board.childIds.push(piece.id);
|
||||
board.partMap[`${row},${col}`] = piece.id;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { h, render } from 'preact';
|
||||
import { signal } from '@preact/signals-core';
|
||||
import Phaser from 'phaser';
|
||||
import { createGameContext } from 'boardgame-core';
|
||||
import { GameUI, PromptDialog, CommandLog } from 'boardgame-phaser';
|
||||
import { createInitialState, registry, type TicTacToeState } from './game/tic-tac-toe';
|
||||
import { GameScene, type GameSceneData } from './scenes/GameScene';
|
||||
import './style.css';
|
||||
|
||||
const gameContext = createGameContext<TicTacToeState>(registry, createInitialState);
|
||||
|
||||
const promptSignal = signal<null | Awaited<ReturnType<typeof gameContext.commands.promptQueue.pop>>>(null);
|
||||
const commandLog = signal<Array<{ input: string; result: string; timestamp: number }>>([]);
|
||||
|
||||
gameContext.commands.on('prompt', (event) => {
|
||||
promptSignal.value = event;
|
||||
});
|
||||
|
||||
const originalRun = gameContext.commands.run.bind(gameContext.commands);
|
||||
(gameContext.commands as any).run = async (input: string) => {
|
||||
const result = await originalRun(input);
|
||||
commandLog.value = [
|
||||
...commandLog.value,
|
||||
{
|
||||
input,
|
||||
result: result.success ? `OK: ${JSON.stringify(result.result)}` : `ERR: ${result.error}`,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
];
|
||||
return result;
|
||||
};
|
||||
|
||||
const sceneData: GameSceneData = {
|
||||
state: gameContext.state,
|
||||
commands: gameContext.commands,
|
||||
};
|
||||
|
||||
const phaserConfig: Phaser.Types.Core.GameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: 560,
|
||||
height: 560,
|
||||
parent: 'phaser-container',
|
||||
backgroundColor: '#f9fafb',
|
||||
scene: [],
|
||||
};
|
||||
|
||||
const game = new Phaser.Game(phaserConfig);
|
||||
|
||||
game.scene.add('GameScene', GameScene, true, sceneData);
|
||||
|
||||
const ui = new GameUI({
|
||||
container: document.getElementById('ui-root')!,
|
||||
root: h('div', { className: 'flex flex-col h-screen' },
|
||||
h('div', { className: 'flex-1 relative' },
|
||||
h('div', { id: 'phaser-container', className: 'w-full h-full' }),
|
||||
h(PromptDialog, {
|
||||
prompt: promptSignal.value,
|
||||
onSubmit: (input: string) => {
|
||||
gameContext.commands._tryCommit(input);
|
||||
promptSignal.value = null;
|
||||
},
|
||||
onCancel: () => {
|
||||
gameContext.commands._cancel('cancelled');
|
||||
promptSignal.value = null;
|
||||
},
|
||||
}),
|
||||
),
|
||||
h('div', { className: 'p-4 bg-gray-100 border-t' },
|
||||
h(CommandLog, { entries: commandLog }),
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
|
||||
gameContext.commands.run('setup');
|
||||
@@ -0,0 +1,186 @@
|
||||
import Phaser from 'phaser';
|
||||
import type { TicTacToeState, TicTacToePart } from '@/game/tic-tac-toe';
|
||||
import { ReactiveScene, bindRegion, createInputMapper, createPromptHandler } from 'boardgame-phaser';
|
||||
import type { PromptEvent, MutableSignal, IGameContext } from 'boardgame-core';
|
||||
|
||||
const CELL_SIZE = 120;
|
||||
const BOARD_OFFSET = { x: 100, y: 100 };
|
||||
const BOARD_SIZE = 3;
|
||||
|
||||
export interface GameSceneData {
|
||||
state: MutableSignal<TicTacToeState>;
|
||||
commands: IGameContext<TicTacToeState>['commands'];
|
||||
}
|
||||
|
||||
export class GameScene extends ReactiveScene<TicTacToeState> {
|
||||
private boardContainer!: Phaser.GameObjects.Container;
|
||||
private gridGraphics!: Phaser.GameObjects.Graphics;
|
||||
private inputMapper!: ReturnType<typeof createInputMapper<TicTacToeState>>;
|
||||
private promptHandler!: ReturnType<typeof createPromptHandler<TicTacToeState>>;
|
||||
private activePrompt: PromptEvent | null = null;
|
||||
private turnText!: Phaser.GameObjects.Text;
|
||||
|
||||
constructor() {
|
||||
super('GameScene');
|
||||
}
|
||||
|
||||
init(data: GameSceneData): void {
|
||||
this.state = data.state;
|
||||
this.commands = data.commands;
|
||||
}
|
||||
|
||||
protected onStateReady(_state: TicTacToeState): void {
|
||||
}
|
||||
|
||||
create(): void {
|
||||
this.boardContainer = this.add.container(0, 0);
|
||||
this.gridGraphics = this.add.graphics();
|
||||
this.drawGrid();
|
||||
|
||||
this.watch(() => {
|
||||
const winner = this.state.value.winner;
|
||||
if (winner) {
|
||||
this.showWinner(winner);
|
||||
}
|
||||
});
|
||||
|
||||
this.watch(() => {
|
||||
const currentPlayer = this.state.value.currentPlayer;
|
||||
this.updateTurnText(currentPlayer);
|
||||
});
|
||||
|
||||
this.setupBindings();
|
||||
this.setupInput();
|
||||
}
|
||||
|
||||
protected setupBindings(): void {
|
||||
bindRegion<TicTacToePart>(
|
||||
this.state.value.board,
|
||||
this.state.value.parts,
|
||||
{
|
||||
cellSize: { x: CELL_SIZE, y: CELL_SIZE },
|
||||
offset: BOARD_OFFSET,
|
||||
factory: (part: TicTacToePart, pos: Phaser.Math.Vector2) => {
|
||||
const text = this.add.text(pos.x + CELL_SIZE / 2, pos.y + CELL_SIZE / 2, part.player, {
|
||||
fontSize: '64px',
|
||||
fontFamily: 'Arial',
|
||||
color: part.player === 'X' ? '#3b82f6' : '#ef4444',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
return text;
|
||||
},
|
||||
},
|
||||
this.boardContainer,
|
||||
);
|
||||
}
|
||||
|
||||
private setupInput(): void {
|
||||
this.inputMapper = createInputMapper(this, this.commands);
|
||||
|
||||
this.inputMapper.mapGridClick(
|
||||
{ x: CELL_SIZE, y: CELL_SIZE },
|
||||
BOARD_OFFSET,
|
||||
{ cols: BOARD_SIZE, rows: BOARD_SIZE },
|
||||
(col, row) => {
|
||||
if (this.state.value.winner) return null;
|
||||
|
||||
const currentPlayer = this.state.value.currentPlayer;
|
||||
const board = this.state.value.board;
|
||||
if (board.partMap[`${row},${col}`]) return null;
|
||||
|
||||
return `play ${currentPlayer} ${row} ${col}`;
|
||||
},
|
||||
);
|
||||
|
||||
this.promptHandler = createPromptHandler(this, this.commands, {
|
||||
onPrompt: (prompt) => {
|
||||
this.activePrompt = prompt;
|
||||
},
|
||||
onSubmit: (input) => {
|
||||
if (this.activePrompt) {
|
||||
return this.activePrompt.tryCommit(input);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onCancel: () => {
|
||||
this.activePrompt = null;
|
||||
},
|
||||
});
|
||||
|
||||
this.promptHandler.start();
|
||||
}
|
||||
|
||||
private drawGrid(): void {
|
||||
const g = this.gridGraphics;
|
||||
g.lineStyle(3, 0x6b7280);
|
||||
|
||||
for (let i = 1; i < BOARD_SIZE; i++) {
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y,
|
||||
BOARD_OFFSET.x + i * CELL_SIZE,
|
||||
BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE,
|
||||
);
|
||||
g.lineBetween(
|
||||
BOARD_OFFSET.x,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
BOARD_OFFSET.x + BOARD_SIZE * CELL_SIZE,
|
||||
BOARD_OFFSET.y + i * CELL_SIZE,
|
||||
);
|
||||
}
|
||||
|
||||
g.strokePath();
|
||||
|
||||
this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y - 40, 'Tic-Tac-Toe', {
|
||||
fontSize: '28px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#1f2937',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.turnText = this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 20, '', {
|
||||
fontSize: '20px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#4b5563',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.updateTurnText(this.state.value.currentPlayer);
|
||||
}
|
||||
|
||||
private updateTurnText(player: string): void {
|
||||
if (this.turnText) {
|
||||
this.turnText.setText(`${player}'s turn`);
|
||||
}
|
||||
}
|
||||
|
||||
private showWinner(winner: string): void {
|
||||
const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`;
|
||||
|
||||
this.add.rectangle(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_SIZE * CELL_SIZE,
|
||||
BOARD_SIZE * CELL_SIZE,
|
||||
0x000000,
|
||||
0.6,
|
||||
);
|
||||
|
||||
const winText = this.add.text(
|
||||
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
|
||||
text,
|
||||
{
|
||||
fontSize: '36px',
|
||||
fontFamily: 'Arial',
|
||||
color: '#fbbf24',
|
||||
},
|
||||
).setOrigin(0.5);
|
||||
|
||||
this.tweens.add({
|
||||
targets: winText,
|
||||
scale: 1.2,
|
||||
duration: 500,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#ui-root {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#ui-root > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#phaser-container {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#phaser-container canvas {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user