feat: that's boop for ya
This commit is contained in:
parent
951fbc7045
commit
696872d5d7
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Boop Game</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="ui-root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<PlayerType, Player>;
|
||||||
|
|
||||||
|
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<string, BoopPart>,
|
||||||
|
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<typeof createInitialState>;
|
||||||
|
const registration = createGameCommandRegistry<BoopState>();
|
||||||
|
export const registry = registration.registry;
|
||||||
|
|
||||||
|
export function getPlayer(host: MutableSignal<BoopState>, 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 <player>', async function(cmd) {
|
||||||
|
const [turnPlayer] = cmd.params as [PlayerType];
|
||||||
|
|
||||||
|
const playCmd = await this.prompt(
|
||||||
|
'play <player> <row:number> <col:number> [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 <row:number> <col:number>',
|
||||||
|
(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<BoopState>) {
|
||||||
|
return host.value.board;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCellOccupied(host: MutableSignal<BoopState>, row: number, col: number): boolean {
|
||||||
|
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPartAt(host: MutableSignal<BoopState>, row: number, col: number): BoopPart | null {
|
||||||
|
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function placePiece(host: MutableSignal<BoopState>, 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<BoopState>, 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<BoopState>, 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<number[][]> {
|
||||||
|
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<number[][]> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
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<BoopState>, player: PlayerType): number[][][] {
|
||||||
|
const pieces = host.value.pieces;
|
||||||
|
const piecesArray = Object.values(pieces);
|
||||||
|
const posSet = new Set<string>();
|
||||||
|
|
||||||
|
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<BoopState>, player: PlayerType, lines: number[][][]) {
|
||||||
|
const allPositions = new Set<string>();
|
||||||
|
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<BoopState>, player: PlayerType): number {
|
||||||
|
const pieces = host.value.pieces;
|
||||||
|
return Object.values(pieces).filter(p => p.player === player).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkWinner(host: MutableSignal<BoopState>): 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,
|
||||||
|
};
|
||||||
|
|
@ -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: <App gameModule={gameModule} gameScene={GameScene}/>,
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.mount();
|
||||||
|
|
@ -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<BoopState> {
|
||||||
|
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<BoopPart, Phaser.GameObjects.Container> {
|
||||||
|
constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal<BoopState>) {}
|
||||||
|
|
||||||
|
*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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
#ui-root {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ui-root * {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
@ -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<TState extends Record<string, unknown>>(props: { gameModule: GameModule<TState>, 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 (
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<PhaserGame>
|
||||||
|
<PhaserScene sceneKey="GameScene" scene={scene.value} autoStart data={gameHost.value} />
|
||||||
|
</PhaserGame>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-gray-100 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,46 @@ importers:
|
||||||
specifier: ^3.2.4
|
specifier: ^3.2.4
|
||||||
version: 3.2.4(lightningcss@1.32.0)
|
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:
|
packages/framework:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@preact/signals':
|
'@preact/signals':
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue