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
|
||||
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':
|
||||
|
|
|
|||
Loading…
Reference in New Issue