diff --git a/packages/boop-game/index.html b/packages/boop-game/index.html
new file mode 100644
index 0000000..8a8645e
--- /dev/null
+++ b/packages/boop-game/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Boop Game
+
+
+
+
+
+
diff --git a/packages/boop-game/package.json b/packages/boop-game/package.json
new file mode 100644
index 0000000..dab1c25
--- /dev/null
+++ b/packages/boop-game/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "boop-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/boop-game/src/game/boop.ts b/packages/boop-game/src/game/boop.ts
new file mode 100644
index 0000000..6cc32bd
--- /dev/null
+++ b/packages/boop-game/src/game/boop.ts
@@ -0,0 +1,380 @@
+import {
+ createGameCommandRegistry,
+ Part,
+ MutableSignal,
+ createRegion,
+ createPart,
+ isCellOccupied as isCellOccupiedUtil,
+ getPartAtPosition,
+} from 'boardgame-core';
+
+const BOARD_SIZE = 6;
+const MAX_PIECES_PER_PLAYER = 8;
+const WIN_LENGTH = 3;
+
+export type PlayerType = 'white' | 'black';
+export type PieceType = 'kitten' | 'cat';
+export type WinnerType = PlayerType | 'draw' | null;
+
+export type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>;
+
+type PieceSupply = { supply: number; placed: number };
+
+type Player = {
+ id: PlayerType;
+ kitten: PieceSupply;
+ cat: PieceSupply;
+};
+
+type PlayerData = Record;
+
+export function createInitialState() {
+ return {
+ board: createRegion('board', [
+ { name: 'x', min: 0, max: BOARD_SIZE - 1 },
+ { name: 'y', min: 0, max: BOARD_SIZE - 1 },
+ ]),
+ pieces: {} as Record,
+ currentPlayer: 'white' as PlayerType,
+ winner: null as WinnerType,
+ players: {
+ white: createPlayer('white'),
+ black: createPlayer('black'),
+ },
+ };
+}
+
+function createPlayer(id: PlayerType): Player {
+ return {
+ id,
+ kitten: { supply: MAX_PIECES_PER_PLAYER, placed: 0 },
+ cat: { supply: 0, placed: 0 },
+ };
+}
+
+export type BoopState = ReturnType;
+const registration = createGameCommandRegistry();
+export const registry = registration.registry;
+
+export function getPlayer(host: MutableSignal, player: PlayerType): Player {
+ return host.value.players[player];
+}
+
+export function decrementSupply(player: Player, pieceType: PieceType) {
+ player[pieceType].supply--;
+ player[pieceType].placed++;
+}
+
+export function incrementSupply(player: Player, pieceType: PieceType, count?: number) {
+ player[pieceType].supply += count ?? 1;
+}
+
+registration.add('setup', async function() {
+ const {context} = this;
+ while (true) {
+ const currentPlayer = context.value.currentPlayer;
+ const turnOutput = await this.run<{winner: WinnerType}>(`turn ${currentPlayer}`);
+ if (!turnOutput.success) throw new Error(turnOutput.error);
+
+ context.produce(state => {
+ state.winner = turnOutput.result.winner;
+ if (!state.winner) {
+ state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
+ }
+ });
+ if (context.value.winner) break;
+ }
+
+ return context.value;
+});
+
+registration.add('turn ', async function(cmd) {
+ const [turnPlayer] = cmd.params as [PlayerType];
+
+ const playCmd = await this.prompt(
+ 'play [type:string]',
+ (command) => {
+ const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
+ const pieceType = type === 'cat' ? 'cat' : 'kitten';
+
+ if (player !== turnPlayer) {
+ return `Invalid player: ${player}. Expected ${turnPlayer}.`;
+ }
+ if (!isValidMove(row, col)) {
+ return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
+ }
+ if (isCellOccupied(this.context, row, col)) {
+ return `Cell (${row}, ${col}) is already occupied.`;
+ }
+
+ const playerData = getPlayer(this.context, player);
+ const supply = playerData[pieceType].supply;
+ if (supply <= 0) {
+ return `No ${pieceType}s left in ${player}'s supply.`;
+ }
+ return null;
+ },
+ this.context.value.currentPlayer
+ );
+ const [player, row, col, type] = playCmd.params as [PlayerType, number, number, PieceType?];
+ const pieceType = type === 'cat' ? 'cat' : 'kitten';
+
+ placePiece(this.context, row, col, turnPlayer, pieceType);
+ applyBoops(this.context, row, col, pieceType);
+
+ const graduatedLines = checkGraduation(this.context, turnPlayer);
+ if (graduatedLines.length > 0) {
+ processGraduation(this.context, turnPlayer, graduatedLines);
+ }
+
+ if (countPiecesOnBoard(this.context, turnPlayer) >= MAX_PIECES_PER_PLAYER) {
+ const pieces = this.context.value.pieces;
+ const availableKittens = Object.values(pieces).filter(
+ p => p.player === turnPlayer && p.pieceType === 'kitten'
+ );
+
+ if (availableKittens.length > 0) {
+ const graduateCmd = await this.prompt(
+ 'graduate ',
+ (command) => {
+ const [row, col] = command.params as [number, number];
+ const posKey = `${row},${col}`;
+ const part = availableKittens.find(p => `${p.position[0]},${p.position[1]}` === posKey);
+ if (!part) return `No kitten at (${row}, ${col}).`;
+ return null;
+ },
+ this.context.value.currentPlayer
+ );
+ const [row, col] = graduateCmd.params as [number, number];
+ const part = availableKittens.find(p => p.position[0] === row && p.position[1] === col)!;
+ removePieceFromBoard(this.context, part);
+ const playerData = getPlayer(this.context, turnPlayer);
+ incrementSupply(playerData, 'cat', 1);
+ }
+ }
+
+ const winner = checkWinner(this.context);
+ if (winner) return { winner };
+
+ return { winner: null };
+});
+
+function isValidMove(row: number, col: number): boolean {
+ return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
+}
+
+export function getBoardRegion(host: MutableSignal) {
+ return host.value.board;
+}
+
+export function isCellOccupied(host: MutableSignal, row: number, col: number): boolean {
+ return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
+}
+
+export function getPartAt(host: MutableSignal, row: number, col: number): BoopPart | null {
+ return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
+}
+
+export function placePiece(host: MutableSignal, row: number, col: number, player: PlayerType, pieceType: PieceType) {
+ const board = getBoardRegion(host);
+ const playerData = getPlayer(host, player);
+ const count = playerData[pieceType].placed + 1;
+
+ const piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
+ { regionId: 'board', position: [row, col], player, pieceType },
+ `${player}-${pieceType}-${count}`
+ );
+ host.produce(s => {
+ s.pieces[piece.id] = piece;
+ board.childIds.push(piece.id);
+ board.partMap[`${row},${col}`] = piece.id;
+ });
+ decrementSupply(playerData, pieceType);
+}
+
+export function applyBoops(host: MutableSignal, placedRow: number, placedCol: number, placedType: PieceType) {
+ const board = getBoardRegion(host);
+ const pieces = host.value.pieces;
+ const piecesArray = Object.values(pieces);
+
+ const piecesToBoop: { part: BoopPart; dr: number; dc: number }[] = [];
+
+ for (const part of piecesArray) {
+ const [r, c] = part.position;
+ if (r === placedRow && c === placedCol) continue;
+
+ const dr = Math.sign(r - placedRow);
+ const dc = Math.sign(c - placedCol);
+
+ if (Math.abs(r - placedRow) <= 1 && Math.abs(c - placedCol) <= 1) {
+ const booperIsKitten = placedType === 'kitten';
+ const targetIsCat = part.pieceType === 'cat';
+
+ if (booperIsKitten && targetIsCat) continue;
+
+ piecesToBoop.push({ part, dr, dc });
+ }
+ }
+
+ for (const { part, dr, dc } of piecesToBoop) {
+ const [r, c] = part.position;
+ const newRow = r + dr;
+ const newCol = c + dc;
+
+ if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
+ const pt = part.pieceType;
+ const pl = part.player;
+ const playerData = getPlayer(host, pl);
+ removePieceFromBoard(host, part);
+ incrementSupply(playerData, pt);
+ continue;
+ }
+
+ if (isCellOccupied(host, newRow, newCol)) continue;
+
+ part.position = [newRow, newCol];
+ board.partMap = Object.fromEntries(
+ board.childIds.map(id => {
+ const p = pieces[id];
+ return [p.position.join(','), id];
+ })
+ );
+ }
+}
+
+export function removePieceFromBoard(host: MutableSignal, part: BoopPart) {
+ const board = getBoardRegion(host);
+ const playerData = getPlayer(host, part.player);
+ board.childIds = board.childIds.filter(id => id !== part.id);
+ delete board.partMap[part.position.join(',')];
+ delete host.value.pieces[part.id];
+ playerData[part.pieceType].placed--;
+}
+
+const DIRECTIONS: [number, number][] = [
+ [0, 1],
+ [1, 0],
+ [1, 1],
+ [1, -1],
+];
+
+export function* linesThrough(r: number, c: number): Generator {
+ for (const [dr, dc] of DIRECTIONS) {
+ const minStart = -(WIN_LENGTH - 1);
+ for (let offset = minStart; offset <= 0; offset++) {
+ const startR = r + offset * dr;
+ const startC = c + offset * dc;
+ const endR = startR + (WIN_LENGTH - 1) * dr;
+ const endC = startC + (WIN_LENGTH - 1) * dc;
+
+ if (startR < 0 || startR >= BOARD_SIZE || startC < 0 || startC >= BOARD_SIZE) continue;
+ if (endR < 0 || endR >= BOARD_SIZE || endC < 0 || endC >= BOARD_SIZE) continue;
+
+ const line: number[][] = [];
+ for (let i = 0; i < WIN_LENGTH; i++) {
+ line.push([startR + i * dr, startC + i * dc]);
+ }
+ yield line;
+ }
+ }
+}
+
+export function* allLines(): Generator {
+ const seen = new Set();
+ for (let r = 0; r < BOARD_SIZE; r++) {
+ for (let c = 0; c < BOARD_SIZE; c++) {
+ for (const line of linesThrough(r, c)) {
+ const key = line.map(p => p.join(',')).join(';');
+ if (!seen.has(key)) {
+ seen.add(key);
+ yield line;
+ }
+ }
+ }
+ }
+}
+
+export function hasWinningLine(positions: number[][]): boolean {
+ const posSet = new Set(positions.map(p => `${p[0]},${p[1]}`));
+ for (const line of allLines()) {
+ if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) return true;
+ }
+ return false;
+}
+
+export function checkGraduation(host: MutableSignal, player: PlayerType): number[][][] {
+ const pieces = host.value.pieces;
+ const piecesArray = Object.values(pieces);
+ const posSet = new Set();
+
+ for (const part of piecesArray) {
+ if (part.player === player && part.pieceType === 'kitten') {
+ posSet.add(`${part.position[0]},${part.position[1]}`);
+ }
+ }
+
+ const winningLines: number[][][] = [];
+ for (const line of allLines()) {
+ if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) {
+ winningLines.push(line);
+ }
+ }
+ return winningLines;
+}
+
+export function processGraduation(host: MutableSignal, player: PlayerType, lines: number[][][]) {
+ const allPositions = new Set();
+ for (const line of lines) {
+ for (const [r, c] of line) {
+ allPositions.add(`${r},${c}`);
+ }
+ }
+
+ const board = getBoardRegion(host);
+ const pieces = host.value.pieces;
+ const partsToRemove = Object.values(pieces).filter(
+ p => p.player === player && p.pieceType === 'kitten' && allPositions.has(`${p.position[0]},${p.position[1]}`)
+ );
+
+ for (const part of partsToRemove) {
+ removePieceFromBoard(host, part);
+ }
+
+ const count = partsToRemove.length;
+ const playerData = getPlayer(host, player);
+ incrementSupply(playerData, 'cat', count);
+}
+
+export function countPiecesOnBoard(host: MutableSignal, player: PlayerType): number {
+ const pieces = host.value.pieces;
+ return Object.values(pieces).filter(p => p.player === player).length;
+}
+
+export function checkWinner(host: MutableSignal): WinnerType {
+ const pieces = host.value.pieces;
+ const piecesArray = Object.values(pieces);
+
+ for (const player of ['white', 'black'] as PlayerType[]) {
+ const positions = piecesArray
+ .filter(p => p.player === player && p.pieceType === 'cat')
+ .map(p => p.position);
+ if (hasWinningLine(positions)) return player;
+ }
+
+ return null;
+}
+
+// 命令构建器
+export const commands = {
+ play: (player: PlayerType, row: number, col: number, type?: PieceType) =>
+ `play ${player} ${row} ${col}${type ? ` ${type}` : ''}`,
+ turn: (player: PlayerType) => `turn ${player}`,
+ graduate: (row: number, col: number) => `graduate ${row} ${col}`,
+} as const;
+
+// 导出游戏模块
+export const gameModule = {
+ createInitialState,
+ registry,
+ commands,
+};
diff --git a/packages/boop-game/src/main.tsx b/packages/boop-game/src/main.tsx
new file mode 100644
index 0000000..7edaef3
--- /dev/null
+++ b/packages/boop-game/src/main.tsx
@@ -0,0 +1,13 @@
+import { h } from 'preact';
+import { GameUI } from 'boardgame-phaser';
+import { gameModule } from './game/boop';
+import './style.css';
+import App from "@/ui/App";
+import {GameScene} from "@/scenes/GameScene";
+
+const ui = new GameUI({
+ container: document.getElementById('ui-root')!,
+ root: ,
+});
+
+ui.mount();
diff --git a/packages/boop-game/src/scenes/GameScene.ts b/packages/boop-game/src/scenes/GameScene.ts
new file mode 100644
index 0000000..f327174
--- /dev/null
+++ b/packages/boop-game/src/scenes/GameScene.ts
@@ -0,0 +1,254 @@
+import Phaser from 'phaser';
+import type {BoopState, BoopPart, PlayerType, PieceType} from '@/game/boop';
+import { GameHostScene } from 'boardgame-phaser';
+import { spawnEffect, type Spawner } from 'boardgame-phaser';
+import type { ReadonlySignal } from '@preact/signals-core';
+import {commands} from "@/game/boop";
+
+const BOARD_SIZE = 6;
+const CELL_SIZE = 80;
+const BOARD_OFFSET = { x: 80, y: 100 };
+
+export class GameScene extends GameHostScene {
+ private boardContainer!: Phaser.GameObjects.Container;
+ private gridGraphics!: Phaser.GameObjects.Graphics;
+ private turnText!: Phaser.GameObjects.Text;
+ private infoText!: Phaser.GameObjects.Text;
+ private winnerOverlay?: Phaser.GameObjects.Container;
+
+ constructor() {
+ super('GameScene');
+ }
+
+ create(): void {
+ super.create();
+
+ this.boardContainer = this.add.container(0, 0);
+ this.gridGraphics = this.add.graphics();
+ this.drawGrid();
+
+ this.disposables.add(spawnEffect(new BoopPartSpawner(this, this.gameHost.state)));
+
+ this.watch(() => {
+ const winner = this.state.winner;
+ if (winner) {
+ this.showWinner(winner);
+ } else if (this.winnerOverlay) {
+ this.winnerOverlay.destroy();
+ this.winnerOverlay = undefined;
+ }
+ });
+
+ this.watch(() => {
+ const currentPlayer = this.state.currentPlayer;
+ this.updateTurnText(currentPlayer);
+ });
+
+ this.setupInput();
+ }
+
+ 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;
+ if (this.isCellOccupied(row, col)) return;
+
+ const cmd = commands.play(this.state.currentPlayer, row, col, 'kitten');
+ const error = this.gameHost.onInput(cmd);
+ if (error) {
+ console.warn('Invalid move:', error);
+ }
+ });
+ }
+ }
+ }
+
+ private drawGrid(): void {
+ const g = this.gridGraphics;
+ g.lineStyle(2, 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 - 50, 'Boop Game', {
+ fontSize: '32px',
+ 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 + 30,
+ '',
+ {
+ fontSize: '22px',
+ fontFamily: 'Arial',
+ color: '#4b5563',
+ }
+ ).setOrigin(0.5);
+
+ this.infoText = this.add.text(
+ BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
+ BOARD_OFFSET.y + BOARD_SIZE * CELL_SIZE + 60,
+ 'Click to place kitten. Cats win with 3 in a row!',
+ {
+ fontSize: '16px',
+ fontFamily: 'Arial',
+ color: '#6b7280',
+ }
+ ).setOrigin(0.5);
+
+ this.updateTurnText(this.state.currentPlayer);
+ }
+
+ private updateTurnText(player: PlayerType): void {
+ if (this.turnText) {
+ const whitePieces = this.state.players.white;
+ const blackPieces = this.state.players.black;
+ const current = player === 'white' ? whitePieces : blackPieces;
+
+ this.turnText.setText(
+ `${player.toUpperCase()}'s turn | Kittens: ${current.kitten.supply} | Cats: ${current.cat.supply}`
+ );
+ }
+ }
+
+ private showWinner(winner: PlayerType | 'draw' | null): void {
+ if (this.winnerOverlay) {
+ this.winnerOverlay.destroy();
+ }
+
+ this.winnerOverlay = this.add.container();
+
+ const text = winner === 'draw' ? "It's a draw!" : winner ? `${winner.toUpperCase()} 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.setup('setup');
+ });
+
+ 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: '40px',
+ 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,
+ });
+ }
+
+ private isCellOccupied(row: number, col: number): boolean {
+ return !!this.state.board.partMap[`${row},${col}`];
+ }
+}
+
+class BoopPartSpawner implements Spawner {
+ constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal) {}
+
+ *getData() {
+ for (const part of Object.values(this.state.value.pieces)) {
+ yield part;
+ }
+ }
+
+ getKey(part: BoopPart): string {
+ return part.id;
+ }
+
+ onUpdate(part: BoopPart, obj: Phaser.GameObjects.Container): void {
+ const [row, col] = part.position;
+ const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
+ const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
+ obj.x = x;
+ obj.y = y;
+ }
+
+ onSpawn(part: BoopPart) {
+ const [row, col] = part.position;
+ const x = BOARD_OFFSET.x + col * CELL_SIZE + CELL_SIZE / 2;
+ const y = BOARD_OFFSET.y + row * CELL_SIZE + CELL_SIZE / 2;
+
+ const container = this.scene.add.container(x, y);
+
+ const isCat = part.pieceType === 'cat';
+ const baseColor = part.player === 'white' ? 0xffffff : 0x333333;
+ const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff;
+
+ // 绘制圆形背景
+ const circle = this.scene.add.circle(0, 0, CELL_SIZE * 0.4, baseColor)
+ .setStrokeStyle(3, strokeColor);
+
+ // 添加文字标识
+ const text = isCat ? '🐱' : '🐾';
+ const textObj = this.scene.add.text(0, 0, text, {
+ fontSize: `${isCat ? 40 : 32}px`,
+ fontFamily: 'Arial',
+ }).setOrigin(0.5);
+
+ container.add([circle, textObj]);
+
+ // 添加落子动画
+ container.setScale(0);
+ this.scene.tweens.add({
+ targets: container,
+ scale: 1,
+ duration: 200,
+ ease: 'Back.easeOut',
+ });
+
+ return container;
+ }
+
+ onDespawn(obj: Phaser.GameObjects.Container) {
+ this.scene.tweens.add({
+ targets: obj,
+ alpha: 0,
+ scale: 0.5,
+ duration: 200,
+ ease: 'Back.easeIn',
+ onComplete: () => obj.destroy(),
+ });
+ }
+}
diff --git a/packages/boop-game/src/style.css b/packages/boop-game/src/style.css
new file mode 100644
index 0000000..28b2bb7
--- /dev/null
+++ b/packages/boop-game/src/style.css
@@ -0,0 +1,9 @@
+@import "tailwindcss";
+
+#ui-root {
+ pointer-events: none;
+}
+
+#ui-root * {
+ pointer-events: auto;
+}
diff --git a/packages/boop-game/src/ui/App.tsx b/packages/boop-game/src/ui/App.tsx
new file mode 100644
index 0000000..182cfba
--- /dev/null
+++ b/packages/boop-game/src/ui/App.tsx
@@ -0,0 +1,38 @@
+import {useComputed} from '@preact/signals';
+import { createGameHost, type GameModule } from 'boardgame-core';
+import Phaser from 'phaser';
+import { h } from 'preact';
+import { PhaserGame, PhaserScene } from 'boardgame-phaser';
+
+export default function App>(props: { gameModule: GameModule, gameScene: { new(): Phaser.Scene } }) {
+
+ const gameHost = useComputed(() => {
+ const gameHost = createGameHost(props.gameModule);
+ return { gameHost };
+ });
+
+ const scene = useComputed(() => new props.gameScene());
+
+ const handleReset = async () => {
+ gameHost.value.gameHost.setup('setup');
+ };
+ const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');
+
+ return (
+
+ );
+}
diff --git a/packages/boop-game/tsconfig.json b/packages/boop-game/tsconfig.json
new file mode 100644
index 0000000..d7f3323
--- /dev/null
+++ b/packages/boop-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/boop-game/vite.config.ts b/packages/boop-game/vite.config.ts
new file mode 100644
index 0000000..acb8595
--- /dev/null
+++ b/packages/boop-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 809f8d5..6bb93e7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,6 +15,46 @@ importers:
specifier: ^3.2.4
version: 3.2.4(lightningcss@1.32.0)
+ packages/boop-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/framework:
devDependencies:
'@preact/signals':