diff --git a/packages/onitama-game/index.html b/packages/onitama-game/index.html new file mode 100644 index 0000000..29f4889 --- /dev/null +++ b/packages/onitama-game/index.html @@ -0,0 +1,14 @@ + + + + + + Onitama - boardgame-phaser + + +
+
+
+ + + diff --git a/packages/onitama-game/package.json b/packages/onitama-game/package.json new file mode 100644 index 0000000..57da812 --- /dev/null +++ b/packages/onitama-game/package.json @@ -0,0 +1,26 @@ +{ + "name": "onitama-game", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@preact/signals-core": "^1.5.1", + "boardgame-core": "link:../../../boardgame-core", + "boardgame-phaser": "workspace:*", + "mutative": "^1.3.0", + "phaser": "^3.80.1", + "preact": "^10.19.3" + }, + "devDependencies": { + "@preact/preset-vite": "^2.8.1", + "@preact/signals": "^2.9.0", + "@tailwindcss/vite": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.3.3", + "vite": "^5.1.0" + } +} diff --git a/packages/onitama-game/src/game/onitama.ts b/packages/onitama-game/src/game/onitama.ts new file mode 100644 index 0000000..e9f7571 --- /dev/null +++ b/packages/onitama-game/src/game/onitama.ts @@ -0,0 +1 @@ +export * from "boardgame-core/samples/onitama"; diff --git a/packages/onitama-game/src/main.tsx b/packages/onitama-game/src/main.tsx new file mode 100644 index 0000000..8804b0e --- /dev/null +++ b/packages/onitama-game/src/main.tsx @@ -0,0 +1,13 @@ +import { h } from 'preact'; +import { GameUI } from 'boardgame-phaser'; +import * as gameModule from './game/onitama'; +import './style.css'; +import App from "@/ui/App"; +import {OnitamaScene} from "@/scenes/OnitamaScene"; + +const ui = new GameUI({ + container: document.getElementById('ui-root')!, + root: , +}); + +ui.mount(); diff --git a/packages/onitama-game/src/scenes/OnitamaScene.ts b/packages/onitama-game/src/scenes/OnitamaScene.ts new file mode 100644 index 0000000..e8abb33 --- /dev/null +++ b/packages/onitama-game/src/scenes/OnitamaScene.ts @@ -0,0 +1,454 @@ +import Phaser from 'phaser'; +import type { OnitamaState, PlayerType, Pawn, Card } from '@/game/onitama'; +import { prompts } from '@/game/onitama'; +import { GameHostScene } from 'boardgame-phaser'; +import { spawnEffect, type Spawner } from 'boardgame-phaser'; + +const CELL_SIZE = 80; +const BOARD_OFFSET = { x: 150, y: 100 }; +const BOARD_SIZE = 5; +const CARD_WIDTH = 100; +const CARD_HEIGHT = 140; + +export class OnitamaScene extends GameHostScene { + private boardContainer!: Phaser.GameObjects.Container; + private gridGraphics!: Phaser.GameObjects.Graphics; + private infoText!: Phaser.GameObjects.Text; + private winnerOverlay?: Phaser.GameObjects.Container; + private redCardsContainer!: Phaser.GameObjects.Container; + private blackCardsContainer!: Phaser.GameObjects.Container; + private spareCardContainer!: Phaser.GameObjects.Container; + private cardGraphics!: Phaser.GameObjects.Graphics; + + constructor() { + super('OnitamaScene'); + } + + create(): void { + super.create(); + + this.boardContainer = this.add.container(0, 0); + this.gridGraphics = this.add.graphics(); + this.cardGraphics = this.add.graphics(); + this.drawBoard(); + + this.disposables.add(spawnEffect(new PawnSpawner(this))); + + this.redCardsContainer = this.add.container(0, 0); + this.blackCardsContainer = this.add.container(0, 0); + this.spareCardContainer = this.add.container(0, 0); + + this.addEffect(() => { + this.updateCards(); + }); + + this.addEffect(() => { + const winner = this.state.winner; + if (winner) { + this.showWinner(winner); + } else if (this.winnerOverlay) { + this.winnerOverlay.destroy(); + this.winnerOverlay = undefined; + } + }); + + this.infoText = this.add.text(BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2, BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 30, '', { + fontSize: '20px', + fontFamily: 'Arial', + color: '#4b5563', + }).setOrigin(0.5); + + this.addEffect(() => { + this.updateInfoText(); + }); + + this.setupInput(); + } + + private updateInfoText(): void { + const currentPlayer = this.state.currentPlayer; + if (this.state.winner) { + this.infoText.setText(`${this.state.winner} wins!`); + } else { + this.infoText.setText(`${currentPlayer}'s turn (Turn ${this.state.turn + 1})`); + } + } + + private drawBoard(): void { + const g = this.gridGraphics; + g.lineStyle(2, 0x6b7280); + + for (let i = 0; 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, 'Onitama', { + fontSize: '28px', + fontFamily: 'Arial', + color: '#1f2937', + }).setOrigin(0.5); + } + + private setupInput(): void { + for (let row = 0; row < BOARD_SIZE; row++) { + for (let col = 0; col < BOARD_SIZE; col++) { + const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2; + const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2; + + const zone = this.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive(); + + zone.on('pointerdown', () => { + if (this.state.winner) return; + this.handleCellClick(col, row); + }); + } + } + } + + private selectedPiece: { x: number, y: number } | null = null; + + private handleCellClick(x: number, y: number): void { + const pawn = this.getPawnAtPosition(x, y); + + if (this.selectedPiece) { + if (pawn && pawn.owner === this.state.currentPlayer) { + this.selectedPiece = { x, y }; + this.highlightValidMoves(); + return; + } + + const fromX = this.selectedPiece.x; + const fromY = this.selectedPiece.y; + this.selectedPiece = null; + + if (pawn && pawn.owner === this.state.currentPlayer) { + return; + } + + this.tryMove(fromX, fromY, x, y); + } else { + if (pawn && pawn.owner === this.state.currentPlayer) { + this.selectedPiece = { x, y }; + this.highlightValidMoves(); + } + } + } + + private highlightValidMoves(): void { + if (!this.selectedPiece) return; + + const currentPlayer = this.state.currentPlayer; + const cardNames = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards; + const moves = this.getValidMovesForPiece(this.selectedPiece.x, this.selectedPiece.y, cardNames); + + moves.forEach(move => { + const x = BOARD_OFFSET.x + move.toX * CELL_SIZE + CELL_SIZE / 2; + const y = BOARD_OFFSET.y + move.toY * CELL_SIZE + CELL_SIZE / 2; + + const highlight = this.add.circle(x, y, CELL_SIZE / 3, 0x3b82f6, 0.3).setDepth(100); + highlight.setInteractive({ useHandCursor: true }); + highlight.on('pointerdown', () => { + this.selectedPiece = null; + this.clearHighlights(); + this.tryMove(move.fromX, move.fromY, move.toX, move.toY); + }); + }); + } + + private clearHighlights(): void { + this.children.list.forEach(child => { + if ('depth' in child && child.depth === 100) { + child.destroy(); + } + }); + } + + private getValidMovesForPiece(fromX: number, fromY: number, cardNames: string[]): Array<{ card: string, fromX: number, fromY: number, toX: number, toY: number }> { + const moves: Array<{ card: string, fromX: number, fromY: number, toX: number, toY: number }> = []; + const player = this.state.currentPlayer; + + for (const cardName of cardNames) { + const card = this.state.cards[cardName]; + if (!card) continue; + + for (const move of card.moveCandidates) { + const toX = fromX + move.dx; + const toY = fromY + move.dy; + + if (this.isValidMove(fromX, fromY, toX, toY, player)) { + moves.push({ card: cardName, fromX, fromY, toX, toY }); + } + } + } + + return moves; + } + + private isValidMove(fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): boolean { + if (toX < 0 || toX >= BOARD_SIZE || toY < 0 || toY >= BOARD_SIZE) { + return false; + } + + const targetPawn = this.getPawnAtPosition(toX, toY); + if (targetPawn && targetPawn.owner === player) { + return false; + } + + const pawn = this.getPawnAtPosition(fromX, fromY); + if (!pawn || pawn.owner !== player) { + return false; + } + + return true; + } + + private tryMove(fromX: number, fromY: number, toX: number, toY: number): void { + this.clearHighlights(); + + const currentPlayer = this.state.currentPlayer; + const cardNames = currentPlayer === 'red' ? this.state.redCards : this.state.blackCards; + const validMoves = this.getValidMovesForPiece(fromX, fromY, cardNames); + + if (validMoves.length > 0) { + const move = validMoves[0]; + const error = this.gameHost.tryAnswerPrompt( + prompts.move, + currentPlayer, + move.card, + fromX, + fromY, + toX, + toY + ); + if (error) { + console.warn('Invalid move:', error); + } + } + } + + private getPawnAtPosition(x: number, y: number): Pawn | null { + const key = `${x},${y}`; + const pawnId = this.state.regions.board.partMap[key]; + return pawnId ? this.state.pawns[pawnId] : null; + } + + private updateCards(): void { + this.redCardsContainer.removeAll(true); + this.blackCardsContainer.removeAll(true); + this.spareCardContainer.removeAll(true); + this.cardGraphics.clear(); + + this.renderCardHand('red', this.state.redCards, 20, 200, this.redCardsContainer); + this.renderCardHand('black', this.state.blackCards, 20, 400, this.blackCardsContainer); + this.renderSpareCard(this.state.spareCard, 650, 300, this.spareCardContainer); + } + + private renderCardHand(player: PlayerType, cardNames: string[], x: number, y: number, container: Phaser.GameObjects.Container): void { + cardNames.forEach((cardName, index) => { + const card = this.state.cards[cardName]; + if (!card) return; + + const cardObj = this.createCardVisual(card, CARD_WIDTH, CARD_HEIGHT); + cardObj.x = x + index * (CARD_WIDTH + 10); + cardObj.y = y; + container.add(cardObj); + }); + + const label = this.add.text(x, y - 30, `${player.toUpperCase()}'s Cards`, { + fontSize: '16px', + fontFamily: 'Arial', + color: player === 'red' ? '#ef4444' : '#3b82f6', + }); + container.add(label); + } + + private renderSpareCard(cardName: string, x: number, y: number, container: Phaser.GameObjects.Container): void { + const card = this.state.cards[cardName]; + if (!card) return; + + const cardObj = this.createCardVisual(card, CARD_WIDTH, CARD_HEIGHT); + cardObj.x = x; + cardObj.y = y; + container.add(cardObj); + + const label = this.add.text(x, y - 30, 'Spare Card', { + fontSize: '16px', + fontFamily: 'Arial', + color: '#6b7280', + }).setOrigin(0.5, 0); + container.add(label); + } + + private createCardVisual(card: Card, width: number, height: number): Phaser.GameObjects.Container { + const container = this.add.container(0, 0); + + const bg = this.add.rectangle(0, 0, width, height, 0xf9fafb, 1) + .setStrokeStyle(2, 0x6b7280); + container.add(bg); + + const title = this.add.text(0, -height / 2 + 15, card.id, { + fontSize: '12px', + fontFamily: 'Arial', + color: '#1f2937', + }).setOrigin(0.5); + container.add(title); + + const grid = this.add.graphics(); + const cellSize = 16; + const gridWidth = 5 * cellSize; + const gridHeight = 5 * cellSize; + const gridStartX = -gridWidth / 2; + const gridStartY = -gridHeight / 2 + 30; + + for (let row = 0; row < 5; row++) { + for (let col = 0; col < 5; col++) { + const x = gridStartX + col * cellSize; + const y = gridStartY + row * cellSize; + + if (row === 2 && col === 2) { + grid.fillStyle(0x3b82f6, 1); + grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); + } else { + const isTarget = card.moveCandidates.some(m => m.dx === col - 2 && m.dy === 2 - row); + if (isTarget) { + grid.fillStyle(0xef4444, 0.6); + grid.fillCircle(x + cellSize / 2, y + cellSize / 2, cellSize / 3); + } + } + } + } + container.add(grid); + + const playerText = this.add.text(0, height / 2 - 15, card.startingPlayer, { + fontSize: '10px', + fontFamily: 'Arial', + color: '#6b7280', + }).setOrigin(0.5); + container.add(playerText); + + return container; + } + + private showWinner(winner: string): void { + if (this.winnerOverlay) { + this.winnerOverlay.destroy(); + } + + this.winnerOverlay = this.add.container(); + + const text = winner === 'draw' ? "It's a draw!" : `${winner} wins!`; + + const bg = 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, + ).setInteractive({ useHandCursor: true }); + + bg.on('pointerdown', () => { + this.gameHost.start(); + }); + + this.winnerOverlay.add(bg); + + 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.winnerOverlay.add(winText); + + this.tweens.add({ + targets: winText, + scale: 1.2, + duration: 500, + yoyo: true, + repeat: 1, + }); + } +} + +class PawnSpawner implements Spawner { + constructor(public readonly scene: OnitamaScene) {} + + *getData() { + for (const pawn of Object.values(this.scene.state.pawns)) { + if (pawn.regionId === 'board') { + yield pawn; + } + } + } + + getKey(pawn: Pawn): string { + return pawn.id; + } + + onUpdate(pawn: Pawn, obj: Phaser.GameObjects.Container): void { + const [x, y] = pawn.position; + obj.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; + obj.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; + } + + onSpawn(pawn: Pawn) { + const container = this.scene.add.container(0, 0); + + const bgColor = pawn.owner === 'red' ? 0xef4444 : 0x3b82f6; + const circle = this.scene.add.circle(0, 0, CELL_SIZE / 3, bgColor, 1) + .setStrokeStyle(2, 0x1f2937); + container.add(circle); + + const label = pawn.type === 'master' ? 'M' : 'S'; + const text = this.scene.add.text(0, 0, label, { + fontSize: '24px', + fontFamily: 'Arial', + color: '#ffffff', + }).setOrigin(0.5); + container.add(text); + + const [x, y] = pawn.position; + container.x = BOARD_OFFSET.x + x * CELL_SIZE + CELL_SIZE / 2; + container.y = BOARD_OFFSET.y + y * CELL_SIZE + CELL_SIZE / 2; + + container.setScale(0); + this.scene.tweens.add({ + targets: container, + scale: 1, + duration: 300, + ease: 'Back.easeOut', + }); + + return container; + } + + onDespawn(obj: Phaser.GameObjects.Container) { + this.scene.tweens.add({ + targets: obj, + scale: 0, + alpha: 0, + duration: 300, + ease: 'Back.easeIn', + onComplete: () => obj.destroy(), + }); + } +} diff --git a/packages/onitama-game/src/style.css b/packages/onitama-game/src/style.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/packages/onitama-game/src/style.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/packages/onitama-game/src/ui/App.tsx b/packages/onitama-game/src/ui/App.tsx new file mode 100644 index 0000000..53b50ba --- /dev/null +++ b/packages/onitama-game/src/ui/App.tsx @@ -0,0 +1,38 @@ +import {useComputed} from '@preact/signals'; +import { createGameHost } from 'boardgame-core'; +import Phaser from 'phaser'; +import { h } from 'preact'; +import { PhaserGame, PhaserScene } from 'boardgame-phaser'; + +export default function App(props: { gameModule: any, gameScene: { new(): Phaser.Scene } }) { + + const gameHost = useComputed(() => { + const gameHost = createGameHost(props.gameModule); + return { gameHost }; + }); + + const scene = useComputed(() => new props.gameScene()); + + const handleReset = () => { + gameHost.value.gameHost.start(); + }; + const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start'); + + return ( +
+
+ +
+
+ + + +
+
+ ); +} diff --git a/packages/onitama-game/tsconfig.json b/packages/onitama-game/tsconfig.json new file mode 100644 index 0000000..d7f3323 --- /dev/null +++ b/packages/onitama-game/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, + "jsx": "react-jsx", + "jsxImportSource": "preact", + "noEmit": true, + "declaration": false, + "declarationMap": false, + "sourceMap": false + }, + "include": ["src/**/*"] +} diff --git a/packages/onitama-game/vite.config.ts b/packages/onitama-game/vite.config.ts new file mode 100644 index 0000000..acb8595 --- /dev/null +++ b/packages/onitama-game/vite.config.ts @@ -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'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 677ec14..2b7dd04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,46 @@ importers: specifier: ^3.2.4 version: 3.2.4(lightningcss@1.32.0) + packages/onitama-game: + dependencies: + '@preact/signals-core': + specifier: ^1.5.1 + version: 1.14.1 + boardgame-core: + specifier: link:../../../boardgame-core + version: link:../../../boardgame-core + boardgame-phaser: + specifier: workspace:* + version: link:../framework + mutative: + specifier: ^1.3.0 + version: 1.3.0 + phaser: + specifier: ^3.80.1 + version: 3.90.0 + preact: + specifier: ^10.19.3 + version: 10.29.0 + devDependencies: + '@preact/preset-vite': + specifier: ^2.8.1 + version: 2.10.5(@babel/core@7.29.0)(preact@10.29.0)(rollup@4.60.1)(vite@5.4.21(lightningcss@1.32.0)) + '@preact/signals': + specifier: ^2.9.0 + version: 2.9.0(preact@10.29.0) + '@tailwindcss/vite': + specifier: ^4.0.0 + version: 4.2.2(vite@5.4.21(lightningcss@1.32.0)) + tailwindcss: + specifier: ^4.0.0 + version: 4.2.2 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vite: + specifier: ^5.1.0 + version: 5.4.21(lightningcss@1.32.0) + packages/regicide-game: dependencies: '@preact/signals-core':