Compare commits

..

No commits in common. "2f0fb2bca844350edbae3b0477935aee3d47198d" and "72e159e52b321005349d8be2c718e44449287313" have entirely different histories.

26 changed files with 630 additions and 964 deletions

View File

@ -11,7 +11,6 @@
"@preact/signals-core": "^1.5.1",
"boardgame-core": "link:../../../boardgame-core",
"boardgame-phaser": "workspace:*",
"inline-schema": "link:../../../inline-schema",
"mutative": "^1.3.0",
"phaser": "^3.80.1",
"preact": "^10.19.3"

View File

@ -0,0 +1,434 @@
import {
createGameCommandRegistry,
Part,
MutableSignal,
createRegion,
createPartPool,
createPart,
moveToRegion,
isCellOccupied as isCellOccupiedUtil,
getPartAtPosition,
applyAlign,
} 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 RegionId = 'board' | 'white-kitten' | 'white-cat' | 'black-kitten' | 'black-cat';
export type BoopPart = Part<{ player: PlayerType; pieceType: PieceType }>;
type Player = {
id: PlayerType;
kittenPool: ReturnType<typeof createPartPool<{ player: PlayerType; pieceType: PieceType }>>;
catPool: ReturnType<typeof createPartPool<{ player: PlayerType; pieceType: PieceType }>>;
graduatedCount: number; // 已毕业但未放置的大猫数量
};
type PlayerData = Record<PlayerType, Player>;
export type BoopState = {
board: ReturnType<typeof createRegion>;
pieces: Record<string, BoopPart>;
currentPlayer: PlayerType;
winner: WinnerType;
players: PlayerData;
};
function createPlayer(id: PlayerType): Player {
return {
id,
kittenPool: createPartPool(
{ regionId: `${id}-kitten`, player: id, pieceType: 'kitten' as PieceType },
MAX_PIECES_PER_PLAYER,
`${id}-kitten`
),
catPool: createPartPool(
{ regionId: `${id}-cat`, player: id, pieceType: 'cat' as PieceType },
MAX_PIECES_PER_PLAYER, // 预创建 MAX_PIECES_PER_PLAYER 只猫
`${id}-cat`
),
graduatedCount: 0,
};
}
export function createInitialState(): BoopState {
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'),
},
};
}
export type BoopStateType = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopStateType>();
export const registry = registration.registry;
export function getPlayer(host: MutableSignal<BoopStateType>, player: PlayerType): Player {
return host.value.players[player];
}
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);
if (pieceType === 'kitten') {
if (playerData.kittenPool.remaining() <= 0) {
return `No kittens left in ${player}'s supply.`;
}
} else {
// Can place cat if pool has remaining OR graduatedCount > 0
const availableCats = playerData.catPool.remaining() + playerData.graduatedCount;
if (availableCats <= 0) {
return `No cats available for ${player}. Graduate some kittens first.`;
}
}
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 part = availableKittens.find(p => p.position[0] === row && p.position[1] === col);
if (!part) return `No kitten at (${row}, ${col}).`;
return null;
},
this.context.value.currentPlayer
);
const [row, col] = graduateCmd.params as [number, number];
graduatePiece(this.context, row, col, turnPlayer);
}
}
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<BoopStateType>) {
return host.value.board;
}
export function isCellOccupied(host: MutableSignal<BoopStateType>, row: number, col: number): boolean {
return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]);
}
export function getPartAt(host: MutableSignal<BoopStateType>, row: number, col: number): BoopPart | null {
return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null;
}
export function placePiece(host: MutableSignal<BoopStateType>, row: number, col: number, player: PlayerType, pieceType: PieceType) {
const board = getBoardRegion(host);
const playerData = getPlayer(host, player);
let piece: BoopPart;
if (pieceType === 'kitten') {
const drawn = playerData.kittenPool.draw();
if (!drawn) throw new Error(`No kitten available in ${player}'s supply`);
piece = drawn;
moveToRegion(piece, null, board, [row, col]);
} else {
// Try to use graduated count first
if (playerData.graduatedCount > 0) {
// Create a new cat piece (graduated)
const count = playerData.catPool.remaining() + playerData.graduatedCount;
piece = createPart<{ player: PlayerType; pieceType: PieceType }>(
{ regionId: 'board', position: [row, col], player, pieceType },
`${player}-cat-graduated-${count}`
);
playerData.graduatedCount--;
} else {
const drawn = playerData.catPool.draw();
if (!drawn) throw new Error(`No cat available in ${player}'s supply`);
piece = drawn;
moveToRegion(piece, null, board, [row, col]);
}
}
host.produce(s => {
s.pieces[piece.id] = piece;
});
}
export function applyBoops(host: MutableSignal<BoopStateType>, 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 }[] = [];
const piecesOffBoard: BoopPart[] = [];
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 });
}
}
host.produce(state => {
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) {
// Mark for removal
piecesOffBoard.push(part);
delete state.pieces[part.id];
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
continue;
}
if (isCellOccupied(host, newRow, newCol)) continue;
moveToRegion(part, board, board, [newRow, newCol]);
}
});
// Return booped pieces to pools outside of produce
for (const part of piecesOffBoard) {
const playerData = getPlayer(host, part.player);
const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool;
pool.return(part);
}
}
export function graduatePiece(host: MutableSignal<BoopStateType>, row: number, col: number, player: PlayerType) {
const pieces = host.value.pieces;
const part = Object.values(pieces).find(p => p.player === player && p.pieceType === 'kitten' && p.position[0] === row && p.position[1] === col);
if (!part) return;
const board = getBoardRegion(host);
const playerData = getPlayer(host, player);
host.produce(state => {
// Remove from board
delete state.pieces[part.id];
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
});
// Return kitten to supply
playerData.kittenPool.return(part);
// Increment graduated count (available cats to place)
playerData.graduatedCount++;
}
export function removePieceFromBoard(host: MutableSignal<BoopStateType>, part: BoopPart) {
const board = getBoardRegion(host);
const playerData = getPlayer(host, part.player);
const pool = part.pieceType === 'kitten' ? playerData.kittenPool : playerData.catPool;
host.produce(state => {
delete state.pieces[part.id];
state.board.childIds = state.board.childIds.filter(id => id !== part.id);
});
pool.return(part);
}
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<BoopStateType>, 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<BoopStateType>, 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 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) {
graduatePiece(host, part.position[0], part.position[1], player);
}
}
export function countPiecesOnBoard(host: MutableSignal<BoopStateType>, player: PlayerType): number {
const pieces = host.value.pieces;
return Object.values(pieces).filter(p => p.player === player).length;
}
export function checkWinner(host: MutableSignal<BoopStateType>): 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,
};

View File

@ -1,250 +0,0 @@
import {
BOARD_SIZE,
BoopState,
BoopPart,
PieceType,
PlayerType,
WinnerType,
WIN_LENGTH,
MAX_PIECES_PER_PLAYER,
BoopGame,
} from "./data";
import {createGameCommandRegistry, Command, moveToRegion} from "boardgame-core";
import {
findPartAtPosition,
findPartInRegion,
getLineCandidates,
getNeighborPositions,
isCellOccupied,
isInBounds
} from "./utils";
export const registry = createGameCommandRegistry<BoopState>();
/**
*
*/
async function place(game: BoopGame, row: number, col: number, player: PlayerType, type: PieceType) {
const value = game.value;
// 从玩家supply中找到对应类型的棋子
const part = findPartInRegion(game, player, type);
if (!part) {
throw new Error(`${player} 的 supply 中没有可用的 ${type}`);
}
const partId = part.id;
await game.produceAsync((state: BoopState) => {
// 将棋子从supply移动到棋盘
const part = state.pieces[partId];
moveToRegion(part, state.regions[player], state.regions.board, [row, col]);
});
return { row, col, player, type, partId };
}
const placeCommand = registry.register( 'place <row:number> <col:number> <player> <type>', place);
/**
* boop -
*/
async function boop(game: BoopGame, row: number, col: number, type: PieceType) {
const booped: string[] = [];
await game.produceAsync((state: BoopState) => {
// 按照远离放置位置的方向推动
for (const [dr, dc] of getNeighborPositions()) {
const nr = row + dr;
const nc = col + dc;
if (!isInBounds(nr, nc)) continue;
// 从 state 中查找,而不是 game
const part = findPartAtPosition(state, nr, nc);
if (!part) continue;
// 小猫不能推动猫
if (type === 'kitten' && part.type === 'cat') continue;
// 计算推动后的位置
const newRow = nr + dr;
const newCol = nc + dc;
// 检查新位置是否为空或在棋盘外
if (!isInBounds(newRow, newCol)) {
// 棋子被推出棋盘,返回玩家supply
booped.push(part.id);
moveToRegion(part, state.regions.board, state.regions[part.player]);
} else if (!isCellOccupied(state, newRow, newCol)) {
// 新位置为空,移动过去
booped.push(part.id);
moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]);
}
// 如果新位置被占用,则不移动(两个棋子都保持原位)
}
});
return { booped };
}
const boopCommand = registry.register('boop <row:number> <col:number> <type>', boop);
/**
* (线)
*/
async function checkWin(game: BoopGame) {
for(const line of getLineCandidates()){
let whites = 0;
let blacks = 0;
for(const [row, col] of line){
const part = findPartAtPosition(game, row, col);
if(part?.type !== 'cat') continue;
if (part.player === 'white') whites++;
else blacks++;
}
if(whites >= WIN_LENGTH) {
return 'white';
}
if(blacks >= WIN_LENGTH) {
return 'black';
}
}
return null;
}
const checkWinCommand = registry.register('check-win', checkWin);
/**
* (线)
*/
async function checkGraduates(game: BoopGame){
const toUpgrade = new Set<string>();
for(const line of getLineCandidates()){
let whites = 0;
let blacks = 0;
for(const [row, col] of line){
const part = findPartAtPosition(game, row, col);
if (part?.player === 'white') whites++;
else if(part?.player === 'black') blacks++;
}
const player = whites >= WIN_LENGTH ? 'white' : blacks >= WIN_LENGTH ? 'black' : null;
if(!player) continue;
for(const [row, col] of line){
const part = findPartAtPosition(game, row, col);
part && toUpgrade.add(part.id);
}
}
await game.produceAsync((state: BoopState) => {
for(const partId of toUpgrade){
const part = state.pieces[partId];
const [row, col] = part.position;
const player = part.player;
moveToRegion(part, state.regions.board, null);
const newPart = findPartInRegion(state, '', 'cat', player);
moveToRegion(newPart || part, null, state.regions[player], [row, col]);
}
});
}
const checkGraduatesCommand = registry.register('check-graduates', checkGraduates);
async function setup(game: BoopGame) {
while (true) {
const currentPlayer = game.value.currentPlayer;
const { winner } = await turnCommand(game, currentPlayer);
await game.produceAsync((state: BoopState) => {
state.winner = winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
}
});
if (game.value.winner) break;
}
return game.value;
}
registry.register('setup', setup);
async function checkFullBoard(game: BoopGame, turnPlayer: PlayerType){
// 检查8-piece规则: 如果玩家所有8个棋子都在棋盘上且没有获胜,强制升级一个小猫
const playerPieces = Object.values(game.value.pieces).filter(
(p: BoopPart) => p.player === turnPlayer && p.regionId === 'board'
);
if(playerPieces.length < MAX_PIECES_PER_PLAYER || game.value.winner !== null){
return;
}
const partId = await game.prompt(
'play <player> <row:number> <col:number> [type:string]',
(command: Command) => {
const [player, row, col] = command.params as [PlayerType, number, number];
if (player !== turnPlayer) {
throw `无效的玩家: ${player},期望的是 ${turnPlayer}`;
}
if (!isInBounds(row, col)) {
throw `无效的位置: (${row}, ${col}),必须在 0 到 ${BOARD_SIZE - 1} 之间。`;
}
const part = findPartAtPosition(game, row, col);
if (!part || part.player !== turnPlayer) {
throw `(${row}, ${col}) 位置没有 ${player} 的棋子。`;
}
return part.id;
}
);
await game.produceAsync((state: BoopState) => {
const part = state.pieces[partId];
moveToRegion(part, state.regions.board, null);
const cat = findPartInRegion(state, '', 'cat');
moveToRegion(cat || part, null, state.regions[turnPlayer]);
});
}
async function turn(game: BoopGame, turnPlayer: PlayerType) {
const {row, col, type} = await game.prompt(
'play <player> <row:number> <col:number> [type:string]',
(command: Command) => {
const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?];
const pieceType = type === 'cat' ? 'cat' : 'kitten';
if (player !== turnPlayer) {
throw `无效的玩家: ${player},期望的是 ${turnPlayer}`;
}
if (!isInBounds(row, col)) {
throw `无效的位置: (${row}, ${col}),必须在 0 到 ${BOARD_SIZE - 1} 之间。`;
}
if (isCellOccupied(game, row, col)) {
throw `单元格 (${row}, ${col}) 已被占用。`;
}
const found = findPartInRegion(game, player, pieceType);
if (!found) {
throw `${player} 的 supply 中没有 ${pieceType === 'cat' ? '大猫' : '小猫'} 了。`;
}
return {player, row,col,type};
},
game.value.currentPlayer
);
const pieceType = type === 'cat' ? 'cat' : 'kitten';
await placeCommand(game, row, col, turnPlayer, pieceType);
await boopCommand(game, row, col, pieceType);
const winner = await checkWinCommand(game);
if(winner) return { winner: winner as WinnerType };
await checkGraduatesCommand(game);
await checkFullBoard(game, turnPlayer);
return { winner: null };
}
const turnCommand = registry.register('turn <player>', turn);
export const commands = {
play: (player: PlayerType, row: number, col: number, type?: PieceType) => {
if (type) {
return `play ${player} ${row} ${col} ${type}`;
}
return `play ${player} ${row} ${col}`;
},
};

View File

@ -1,55 +0,0 @@
import parts from './parts.csv';
import {createRegion, moveToRegion, Region} from "boardgame-core";
import {createPartsFromTable} from "boardgame-core";
import {Part} from "boardgame-core";
import {IGameContext} from "boardgame-core";
export const BOARD_SIZE = 6;
export const MAX_PIECES_PER_PLAYER = 8;
export const WIN_LENGTH = 3;
export type PlayerType = 'white' | 'black';
export type PieceType = 'kitten' | 'cat';
export type WinnerType = PlayerType | 'draw' | null;
export type RegionType = 'white' | 'black' | 'board' | '';
export type BoopPartMeta = { player: PlayerType; type: PieceType };
export type BoopPart = Part<BoopPartMeta>;
export function createInitialState() {
const pieces = createPartsFromTable(
parts,
(item: {player: string, type: string}, index: number) => `${item.player}-${item.type}-${index + 1}`,
(item: {count: number}) => item.count
) as Record<string, BoopPart>;
// Initialize region childIds
const whiteRegion = createRegion('white', []);
const blackRegion = createRegion('black', []);
const boardRegion = createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]);
// Populate region childIds based on piece regionId
for (const part of Object.values(pieces)) {
if(part.type !== 'kitten') continue;
if (part.player === 'white' ) {
moveToRegion(part, null, whiteRegion);
} else if (part.player === 'black') {
moveToRegion(part, null, blackRegion);
}
}
return {
regions: {
white: whiteRegion,
black: blackRegion,
board: boardRegion,
} as Record<RegionType, Region>,
pieces,
currentPlayer: 'white' as PlayerType,
winner: null as WinnerType,
};
}
export type BoopState = ReturnType<typeof createInitialState>;
export type BoopGame = IGameContext<BoopState>;

View File

@ -1,2 +0,0 @@
export * from './data';
export * from './commands';

View File

@ -1,6 +0,0 @@
type,player,count
string,string,int
kitten,white,8
kitten,black,8
cat,white,8
cat,black,8
1 type player count
2 string string int
3 kitten white 8
4 kitten black 8
5 cat white 8
6 cat black 8

View File

@ -1,8 +0,0 @@
type Table = {
type: string;
player: string;
count: number;
}[];
declare const data: Table;
export default data;

View File

@ -1,66 +0,0 @@
# Boop
## Game Overview
**"boop."** is a deceptively cute, oh-so-snoozy strategy game. Players compete to place their cats on a quilted bed, pushing other pieces out of the way.
- **Players:** 2
- **Ages:** 10+
- **Play Time:** 1520 minutes
## Components
- 1 Quilted Fabric Board (the "Bed") — 6×6 grid
- 8 White Kittens and 8 White Cats
- 8 Black Kittens and 8 Black Cats
## Objective
Be the first player to line up **three Cats** in a row (horizontally, vertically, or diagonally) on the 6×6 grid.
## Setup
- Each player takes their 8 Kittens into their personal supply.
- Cats are kept off to the side until a player "graduates" their Kittens.
- The board starts empty.
## How to Play
On your turn, perform the following steps:
### 1. Placing Pieces
Place one piece (Kitten or Cat) from your supply onto any empty space on the bed.
### 2. The "Boop" Mechanic
Placing a piece causes a **"boop."** Every piece (yours or your opponent's) in the 8 spaces immediately surrounding the piece you just played is pushed one space away from the placed piece.
- **Chain Reactions:** A "booped" piece does **not** cause another boop. Only the piece being *placed* triggers boops.
- **Obstructions:** If there is a piece behind the piece being booped (i.e., the space it would be pushed into is occupied), the boop does not happen — both pieces stay put.
- **Falling off the Bed:** If a piece is booped off the edge of the 6×6 grid, it is returned to its owner's supply.
### 3. Kittens vs. Cats (The Hierarchy)
- **Kittens** can boop other Kittens.
- **Kittens** **cannot** boop Cats.
- **Cats** can boop both Kittens and other Cats.
## Graduation (Getting Cats)
To win, you need Cats. You obtain Cats by lining up Kittens:
1. **Three in a Row:** If you line up three of your Kittens in a row (horizontally, vertically, or diagonally), they "graduate."
2. **The Process:** Remove the three Kittens from the board and return them to the box. Add three **Cats** to your personal supply.
3. **Multiple Rows:** If placing a piece creates multiple rows of three, you graduate all pieces involved in those rows.
4. **The 8-Piece Rule:** If a player has all 8 of their pieces on the board (a mix of Kittens and Cats) and no one has three-in-a-row, the player must choose one of their Kittens on the board to graduate into a Cat to free up a piece.
## How to Win
A player wins immediately when they get **three Cats in a row** on the bed (horizontally, vertically, or diagonally).
> **Note:** If you line up three Cats during a Kitten graduation move (e.g., three Cats are moved into a row because of a Kitten being placed), you also win.
## Strategy Tips
Because every move pushes other pieces away, players must think several steps ahead to "trap" their own pieces into a row while knocking their opponent's pieces off the board or out of alignment.

View File

@ -1,76 +0,0 @@
import {
BOARD_SIZE,
BoopGame,
BoopPart,
BoopState,
PieceType,
PlayerType,
RegionType,
WIN_LENGTH
} from "./data";
const DIRS = [
[0, 1],
[1, 0],
[1, 1],
[-1, 1]
]
type PT = [number, number];
type Line = PT[];
export function* getLineCandidates(){
for(const [dx, dy] of DIRS){
for(let x = 0; x < BOARD_SIZE; x ++)
for(let y = 0; y < BOARD_SIZE; y ++){
if(!isInBounds(x + dx * (WIN_LENGTH-1), y + dy * (WIN_LENGTH-1))) continue;
const line = [];
for(let i = 0; i < WIN_LENGTH; i ++){
line.push([x + i * dx, y + i * dy]);
}
yield line as Line;
}
}
}
/**
*
*/
export function isInBounds(x: number, y: number): boolean {
return x >= 0 && x < BOARD_SIZE && y >= 0 && y < BOARD_SIZE;
}
export function isCellOccupied(game: BoopGame | BoopState, x: number, y: number): boolean {
const id = `${x},${y}`;
return getState(game).regions.board.partMap[id] !== undefined;
}
export function* getNeighborPositions(x: number = 0, y: number = 0){
for(let dx = -1; dx <= 1; dx ++)
for(let dy = -1; dy <= 1; dy ++)
if(dx !== 0 || dy !== 0)
yield [x + dx, y + dy] as PT;
}
export function findPartInRegion(ctx: BoopGame | BoopState, regionId: keyof BoopGame['value']['regions'], type: PieceType, player?: PlayerType): BoopPart | null {
const state = getState(ctx);
if(!regionId){
return Object.values(state.pieces).find(part => match(regionId, part, type, player)) || null;
}
const id = state.regions[regionId].childIds.find((id: string) => match(regionId, state.pieces[id], type, player));
return id ? state.pieces[id] || null : null;
}
function match(regionId: RegionType, part: BoopPart, type: PieceType, player?: PlayerType){
return regionId === part.regionId && part.type === type && (!player || part.player === player);
}
export function findPartAtPosition(ctx: BoopGame | BoopState, row: number, col: number): BoopPart | null {
const state = getState(ctx);
const id = state.regions.board.partMap[`${row},${col}`];
return id ? state.pieces[id] || null : null;
}
function getState(ctx: BoopGame | BoopState): BoopState {
if('value' in ctx){
return ctx.value;
}
return ctx;
}

View File

@ -1,13 +1,13 @@
import { h } from 'preact';
import { GameUI } from 'boardgame-phaser';
import * as boop from './game';
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={boop} gameScene={GameScene}/>,
root: <App gameModule={gameModule} gameScene={GameScene}/>,
});
ui.mount();

View File

@ -1,5 +1,6 @@
import Phaser from 'phaser';
import type { BoopState, PlayerType, BoopPart } from '@/game';
import type { BoopState, PlayerType } from '@/game/boop';
import type { ReadonlySignal } from '@preact/signals-core';
const BOARD_SIZE = 6;
const CELL_SIZE = 80;
@ -32,7 +33,7 @@ export class BoardRenderer {
this.infoText = this.scene.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',
@ -69,30 +70,18 @@ export class BoardRenderer {
}).setOrigin(0.5);
}
private countPieces(state: BoopState, player: PlayerType) {
const pieces = Object.values(state.pieces);
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
const piecesOnBoard = playerPieces.filter((p: BoopPart) => p.regionId === 'board').length;
return { kittensInSupply, catsInSupply, piecesOnBoard };
}
updateTurnText(player: PlayerType, state: BoopState): void {
const { kittensInSupply, catsInSupply } = this.countPieces(state, player);
const current = player === 'white' ? state.players.white : state.players.black;
const catsAvailable = current.catPool.remaining() + current.graduatedCount;
this.turnText.setText(
`${player.toUpperCase()}'s turn | Kittens: ${kittensInSupply} | Cats: ${catsInSupply}`
`${player.toUpperCase()}'s turn | Kittens: ${current.kittenPool.remaining()} | Cats: ${catsAvailable}`
);
}
setupInput(
getState: () => BoopState,
onCellClick: (row: number, col: number) => void,
onPieceClick: (row: number, col: number) => void,
checkWinner: () => boolean
state: ReadonlySignal<BoopState>,
onCellClick: (row: number, col: number) => void
): void {
for (let row = 0; row < BOARD_SIZE; row++) {
for (let col = 0; col < BOARD_SIZE; col++) {
@ -102,13 +91,8 @@ export class BoardRenderer {
const zone = this.scene.add.zone(x, y, CELL_SIZE, CELL_SIZE).setInteractive();
zone.on('pointerdown', () => {
const state = getState();
const isOccupied = !!state.regions.board.partMap[`${row},${col}`];
if (checkWinner()) return;
if (isOccupied) {
onPieceClick(row, col);
} else {
const isOccupied = !!state.value.board.partMap[`${row},${col}`];
if (!isOccupied && !state.value.winner) {
onCellClick(row, col);
}
});

View File

@ -1,105 +0,0 @@
import Phaser from 'phaser';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class ErrorOverlay {
private overlay?: Phaser.GameObjects.Container;
private hideTimeout?: Phaser.Time.TimerEvent;
constructor(private scene: Phaser.Scene) {
// 初始时不显示
}
show(message: string, duration: number = 2000): void {
// 清除之前的定时器
this.hideTimeout?.remove();
// 销毁之前的 overlay
if (this.overlay) {
this.overlay.destroy();
}
this.overlay = this.scene.add.container();
// 半透明背景(可点击关闭)
const bg = this.scene.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE + 200,
BOARD_SIZE * CELL_SIZE + 200,
0x000000,
0.4,
).setInteractive();
bg.on('pointerdown', () => {
this.hide();
});
this.overlay.add(bg);
// 错误提示框(居中显示)
const centerX = BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2;
const centerY = BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2;
const errorBox = this.scene.add.container(centerX, centerY);
// 错误框背景
const boxBg = this.scene.add.rectangle(0, 0, 450, 100, 0xef4444, 0.95)
.setStrokeStyle(4, 0xdc2626);
// 错误图标
const iconText = this.scene.add.text(-140, 0, '❌', {
fontSize: '36px',
}).setOrigin(0.5);
// 错误文本
const errorText = this.scene.add.text(60, 0, message, {
fontSize: '22px',
fontFamily: 'Arial',
color: '#ffffff',
align: 'center',
wordWrap: { width: 300 },
}).setOrigin(0.5);
errorBox.add([boxBg, iconText, errorText]);
this.overlay.add(errorBox);
// 出现动画
errorBox.setScale(0);
errorBox.setAlpha(0);
this.scene.tweens.add({
targets: errorBox,
scale: 1,
alpha: 1,
duration: 300,
ease: 'Back.easeOut',
});
// 自动隐藏
this.hideTimeout = this.scene.time.delayedCall(duration, () => {
this.hide();
});
}
hide(): void {
this.hideTimeout?.remove();
this.hideTimeout = undefined;
if (this.overlay) {
const overlay = this.overlay;
// 消失动画
this.scene.tweens.add({
targets: overlay,
alpha: 0,
duration: 200,
onComplete: () => {
overlay.destroy();
this.overlay = undefined;
},
});
}
}
destroy(): void {
this.hideTimeout?.remove();
this.overlay?.destroy();
}
}

View File

@ -1,21 +1,18 @@
import type { BoopState } from '@/game';
import Phaser from 'phaser';
import type { BoopState, PlayerType, PieceType } from '@/game/boop';
import { GameHostScene } from 'boardgame-phaser';
import { commands } from '@/game';
import { commands } from '@/game/boop';
import { BoardRenderer } from './BoardRenderer';
import { createPieceSpawner } from './PieceSpawner';
import { SupplyUI } from './SupplyUI';
import { PieceTypeSelector } from './PieceTypeSelector';
import { WinnerOverlay } from './WinnerOverlay';
import { StartOverlay } from './StartOverlay';
import { ErrorOverlay } from './ErrorOverlay';
export class GameScene extends GameHostScene<BoopState> {
private boardRenderer!: BoardRenderer;
private supplyUI!: SupplyUI;
private pieceTypeSelector!: PieceTypeSelector;
private winnerOverlay!: WinnerOverlay;
private startOverlay!: StartOverlay;
private errorOverlay!: ErrorOverlay;
constructor() {
super('GameScene');
@ -29,32 +26,17 @@ export class GameScene extends GameHostScene<BoopState> {
this.supplyUI = new SupplyUI(this);
this.pieceTypeSelector = new PieceTypeSelector(this);
this.winnerOverlay = new WinnerOverlay(this, () => this.restartGame());
this.startOverlay = new StartOverlay(this, () => this.startGame());
this.errorOverlay = new ErrorOverlay(this);
// 设置棋子生成器
this.disposables.add(createPieceSpawner(this));
// 设置输入处理(空单元格和已有棋子统一处理)
this.boardRenderer.setupInput(
() => this.state,
(row, col) => this.handleCellClick(row, col),
(row, col) => this.handlePieceClick(row, col),
() => this.gameHost.status.value !== 'running' || !!this.state.winner
);
// 监听游戏状态变化
this.addEffect(() => {
const status = this.gameHost.status.value;
if (status === 'running') {
this.startOverlay.hide();
} else if (status === 'created') {
this.startOverlay.show();
}
// 设置输入处理
this.boardRenderer.setupInput(this.gameHost.state, (row, col) => {
this.handleCellClick(row, col);
});
// 监听胜负状态
this.addEffect(() => {
// 监听状态变化
this.watch(() => {
const winner = this.state.winner;
if (winner) {
this.winnerOverlay.show(winner);
@ -63,12 +45,15 @@ export class GameScene extends GameHostScene<BoopState> {
}
});
this.addEffect(() => {
this.watch(() => {
const currentPlayer = this.state.currentPlayer;
this.boardRenderer.updateTurnText(currentPlayer, this.state);
this.supplyUI.update(this.state);
this.pieceTypeSelector.update(this.state);
});
// 设置棋子类型选择器回调
// 可以在这里添加类型改变时的额外逻辑
}
private handleCellClick(row: number, col: number): void {
@ -76,23 +61,10 @@ export class GameScene extends GameHostScene<BoopState> {
const cmd = commands.play(this.state.currentPlayer, row, col, selectedType);
const error = this.gameHost.onInput(cmd);
if (error) {
this.errorOverlay.show(error);
console.warn('Invalid move:', error);
}
}
private handlePieceClick(row: number, col: number): void {
// 棋盘满时,点击棋子触发升级
const cmd = commands.play(this.state.currentPlayer, row, col);
const error = this.gameHost.onInput(cmd);
if (error) {
this.errorOverlay.show(error);
}
}
private startGame(): void {
this.gameHost.setup('setup');
}
private restartGame(): void {
this.gameHost.setup('setup');
}

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, BoopPart } from '@/game';
import type { BoopState, BoopPart } from '@/game/boop';
import { GameHostScene, spawnEffect, type Spawner } from 'boardgame-phaser';
import { BOARD_OFFSET, CELL_SIZE } from './BoardRenderer';
@ -8,8 +8,7 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
*getData() {
for (const part of Object.values(this.scene.state.pieces)) {
if(part.regionId === 'board')
yield part;
yield part;
}
}
@ -38,7 +37,7 @@ class BoopPartSpawner implements Spawner<BoopPart, Phaser.GameObjects.Container>
const container = this.scene.add.container(x, y);
const isCat = part.type === 'cat';
const isCat = part.pieceType === 'cat';
const baseColor = part.player === 'white' ? 0xffffff : 0x333333;
const strokeColor = part.player === 'white' ? 0x000000 : 0xffffff;

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, PlayerType, PieceType, BoopPart } from '@/game';
import type { BoopState, PlayerType, PieceType } from '@/game/boop';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class PieceTypeSelector {
@ -44,8 +44,6 @@ export class PieceTypeSelector {
bg.setInteractive({ useHandCursor: true });
bg.on('pointerdown', () => {
this.selectedType = type;
// 立即更新按钮高亮状态
this.updateButtonsVisual();
this.typeChangeCallback?.(this.selectedType);
});
@ -56,55 +54,27 @@ export class PieceTypeSelector {
this.typeChangeCallback = callback;
}
/**
*
*/
private updateButtonsVisual(): void {
const kittenBg = this.kittenButton.list[0] as Phaser.GameObjects.Rectangle;
const kittenText = this.kittenButton.list[1] as Phaser.GameObjects.Text;
const catBg = this.catButton.list[0] as Phaser.GameObjects.Rectangle;
const catText = this.catButton.list[1] as Phaser.GameObjects.Text;
// 更新小猫按钮
kittenBg.setFillStyle(this.selectedType === 'kitten' ? 0xfbbf24 : 0xe5e7eb);
// 更新大猫按钮
catBg.setFillStyle(this.selectedType === 'cat' ? 0xfbbf24 : 0xe5e7eb);
}
getSelectedType(): PieceType {
return this.selectedType;
}
private countPieces(state: BoopState, player: PlayerType) {
const pieces = Object.values(state.pieces);
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
const catsOnBoard = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === 'board').length;
return { kittensInSupply, catsInSupply, catsOnBoard };
}
update(state: BoopState): void {
const currentPlayer = state.currentPlayer;
const { kittensInSupply, catsInSupply, catsOnBoard } = this.countPieces(state, currentPlayer);
const kittenAvailable = kittensInSupply > 0;
const catsAvailable = catsInSupply + catsOnBoard > 0;
const currentPlayer = state.players[state.currentPlayer];
const kittenAvailable = currentPlayer.kittenPool.remaining() > 0;
const catsAvailable = currentPlayer.catPool.remaining() + currentPlayer.graduatedCount > 0;
this.updateButton(
this.kittenButton,
kittenAvailable,
this.selectedType === 'kitten',
`🐾 小猫 (${kittensInSupply})`
`🐾 小猫 (${currentPlayer.kittenPool.remaining()})`
);
this.updateButton(
this.catButton,
catsAvailable,
this.selectedType === 'cat',
`🐱 大猫 (${catsInSupply + catsOnBoard})`
`🐱 大猫 (${currentPlayer.catPool.remaining() + currentPlayer.graduatedCount})`
);
// 自动切换到可用类型

View File

@ -1,126 +0,0 @@
import Phaser from 'phaser';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class StartOverlay {
private overlay?: Phaser.GameObjects.Container;
private onStartCallback?: () => void;
constructor(
private scene: Phaser.Scene,
onStart: () => void
) {
this.onStartCallback = onStart;
this.show();
}
show(): void {
if (this.overlay) {
this.overlay.destroy();
}
this.overlay = this.scene.add.container();
// 背景遮罩
const bg = this.scene.add.rectangle(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_SIZE * CELL_SIZE + 200,
BOARD_SIZE * CELL_SIZE + 200,
0x000000,
0.7,
);
this.overlay.add(bg);
// 游戏标题
const titleText = this.scene.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 - 80,
'🐱 BOOP 🐾',
{
fontSize: '60px',
fontFamily: 'Arial',
color: '#fbbf24',
fontStyle: 'bold',
},
).setOrigin(0.5);
this.overlay.add(titleText);
// 游戏说明
const rulesText = this.scene.add.text(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 - 10,
'将小猫或大猫放在棋盘上\n推动周围的小猫\n先连成3个猫的玩家获胜',
{
fontSize: '20px',
fontFamily: 'Arial',
color: '#e5e7eb',
align: 'center',
lineSpacing: 10,
},
).setOrigin(0.5);
this.overlay.add(rulesText);
// 开始按钮
const buttonY = BOARD_OFFSET.y + (BOARD_SIZE * CELL_SIZE) / 2 + 80;
const startButton = this.scene.add.container(
BOARD_OFFSET.x + (BOARD_SIZE * CELL_SIZE) / 2,
buttonY
);
const buttonBg = this.scene.add.rectangle(0, 0, 200, 60, 0xfbbf24)
.setStrokeStyle(3, 0xf59e0b)
.setInteractive({ useHandCursor: true });
const buttonText = this.scene.add.text(0, 0, '开始游戏', {
fontSize: '24px',
fontFamily: 'Arial',
color: '#1f2937',
fontStyle: 'bold',
}).setOrigin(0.5);
startButton.add([buttonBg, buttonText]);
this.overlay.add(startButton);
// 按钮交互效果
buttonBg.on('pointerover', () => {
buttonBg.setFillStyle(0xfcd34d);
});
buttonBg.on('pointerout', () => {
buttonBg.setFillStyle(0xfbbf24);
});
buttonBg.on('pointerdown', () => {
buttonBg.setFillStyle(0xf59e0b);
});
buttonBg.on('pointerup', () => {
buttonBg.setFillStyle(0xfcd34d);
this.onStartCallback?.();
});
// 标题呼吸动画
this.scene.tweens.add({
targets: titleText,
scale: 1.1,
duration: 1000,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut',
});
}
hide(): void {
if (this.overlay) {
this.overlay.destroy();
this.overlay = undefined;
}
}
destroy(): void {
this.hide();
}
}

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, PlayerType, BoopPart } from '@/game';
import type { BoopState, PlayerType } from '@/game/boop';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class SupplyUI {
@ -39,30 +39,19 @@ export class SupplyUI {
this.blackContainer.setDepth(100);
}
private countPieces(state: BoopState, player: PlayerType) {
const pieces = Object.values(state.pieces);
const playerPieces = pieces.filter((p: BoopPart) => p.player === player);
const kittensInSupply = playerPieces.filter((p: BoopPart) => p.type === 'kitten' && p.regionId === player).length;
const catsInSupply = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === player).length;
const catsOnBoard = playerPieces.filter((p: BoopPart) => p.type === 'cat' && p.regionId === 'board').length;
return { kittensInSupply, catsInSupply, catsOnBoard };
}
update(state: BoopState): void {
const white = this.countPieces(state, 'white');
const black = this.countPieces(state, 'black');
const white = state.players.white;
const black = state.players.black;
const whiteCatsAvailable = white.catsInSupply + white.catsOnBoard;
const blackCatsAvailable = black.catsInSupply + black.catsOnBoard;
const whiteCatsAvailable = white.catPool.remaining() + white.graduatedCount;
const blackCatsAvailable = black.catPool.remaining() + black.graduatedCount;
this.whiteText.setText(
`⚪ WHITE\n🐾 ${white.kittensInSupply} | 🐱 ${whiteCatsAvailable}`
`⚪ WHITE\n🐾 ${white.kittenPool.remaining()} | 🐱 ${whiteCatsAvailable}`
);
this.blackText.setText(
`⚫ BLACK\n🐾 ${black.kittensInSupply} | 🐱 ${blackCatsAvailable}`
`⚫ BLACK\n🐾 ${black.kittenPool.remaining()} | 🐱 ${blackCatsAvailable}`
);
this.updateHighlight(state.currentPlayer);

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import type { BoopState, WinnerType } from '@/game';
import type { BoopState, WinnerType } from '@/game/boop';
import { BOARD_OFFSET, CELL_SIZE, BOARD_SIZE } from './BoardRenderer';
export class WinnerOverlay {

View File

@ -14,13 +14,7 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
const scene = useComputed(() => new props.gameScene());
const handleReset = async () => {
gameHost.value.gameHost.setup('setup').then(result => {
if(!result.success) {
console.error(result.error);
}else{
console.log('Game finished!', result.result);
}
});
gameHost.value.gameHost.setup('setup');
};
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');

View File

@ -2,10 +2,9 @@ import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
import {csvLoader} from 'inline-schema/csv-loader/rollup';
export default defineConfig({
plugins: [preact(), tailwindcss(), csvLoader()],
plugins: [preact(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),

View File

@ -1,5 +1,5 @@
import Phaser from 'phaser';
import { effect } from '@preact/signals-core';
import { effect, type ReadonlySignal } from '@preact/signals-core';
import type { GameHost } from 'boardgame-core';
import { DisposableBag, type IDisposable } from '../utils';
@ -14,10 +14,7 @@ export abstract class GameHostScene<TState extends Record<string, unknown>>
implements IDisposable
{
protected disposables = new DisposableBag();
private _gameHost!: GameHost<TState>;
public get gameHost(): GameHost<TState> {
return this._gameHost;
}
protected gameHost!: GameHost<TState>;
addInterruption(promise: Promise<void>){
this.gameHost?.addInterruption(promise);
@ -30,7 +27,7 @@ export abstract class GameHostScene<TState extends Record<string, unknown>>
}
init(data: GameHostSceneOptions<TState>): void {
this._gameHost = data.gameHost;
this.gameHost = data.gameHost;
}
create(): void {
@ -43,14 +40,11 @@ export abstract class GameHostScene<TState extends Record<string, unknown>>
/** 获取当前状态的只读快照 */
public get state(): TState {
return this.gameHost.context.value;
return this.gameHost.state.value;
}
public addDisposable(disposable: IDisposable){
this.disposables.add(disposable);
}
/** 注册响应式监听(场景关闭时自动清理) */
public addEffect(fn: () => CleanupFn): void {
protected watch(fn: () => CleanupFn): void {
this.disposables.add(effect(fn));
}
}

View File

@ -1,3 +1,8 @@
import { h, Fragment } from 'preact';
import { effect } from '@preact/signals-core';
type DisposeFn = () => void;
export interface GameUIOptions {
container: HTMLElement;
root: any;
@ -6,6 +11,7 @@ export interface GameUIOptions {
export class GameUI {
private container: HTMLElement;
private root: any;
private effects: DisposeFn[] = [];
constructor(options: GameUIOptions) {
this.container = options.container;
@ -18,9 +24,19 @@ export class GameUI {
});
}
watch(fn: () => void): DisposeFn {
const e = effect(fn);
this.effects.push(e);
return e;
}
unmount(): void {
import('preact').then(({ render }) => {
render(null, this.container);
});
for (const e of this.effects) {
e();
}
this.effects = [];
}
}

View File

@ -1,19 +1,22 @@
import {
createGameCommandRegistry, Part, createRegion, createPart, isCellOccupied as isCellOccupiedUtil,
IGameContext, Command
createGameCommandRegistry,
type Part,
createRegion,
type MutableSignal,
type GameModule,
} from 'boardgame-core';
const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
const WINNING_LINES: number[][][] = [
[[0, 0], [0, 1], [0, 2]],
[[1, 0], [1, 1], [1, 2]],
[[2, 0], [2, 1], [2, 2]],
[[0, 0], [1, 0], [2, 0]],
[[0, 1], [1, 1], [2, 1]],
[[0, 2], [1, 2], [2, 2]],
[[0, 0], [1, 1], [2, 2]],
[[0, 2], [1, 1], [2, 0]],
[[0, 0], [0, 1], [0, 2]],
[[1, 0], [1, 1], [1, 2]],
[[2, 0], [2, 1], [2, 2]],
[[0, 0], [1, 0], [2, 0]],
[[0, 1], [1, 1], [2, 1]],
[[0, 2], [1, 2], [2, 2]],
[[0, 0], [1, 1], [2, 2]],
[[0, 2], [1, 1], [2, 0]],
];
export type PlayerType = 'X' | 'O';
@ -22,122 +25,137 @@ export type WinnerType = PlayerType | 'draw' | null;
export type TicTacToePart = Part<{ player: PlayerType }>;
export function createInitialState() {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
parts: {} as Record<string, TicTacToePart>,
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
]),
parts: {} as Record<string, TicTacToePart>,
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export type TicTacToeState = ReturnType<typeof createInitialState>;
export type TicTacToeGame = IGameContext<TicTacToeState>;
export const registry = createGameCommandRegistry<TicTacToeState>();
async function setup(game: TicTacToeGame) {
while (true) {
const currentPlayer = game.value.currentPlayer;
const turnNumber = game.value.turn + 1;
const turnOutput = await turnCommand(game, currentPlayer, turnNumber);
if (!turnOutput.success) throw new Error(turnOutput.error);
const registration = createGameCommandRegistry<TicTacToeState>();
export const registry = registration.registry;
game.produce((state: TicTacToeState) => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.turn = turnNumber;
}
});
if (game.value.winner) break;
}
export const gameModule: GameModule<TicTacToeState> = {
registry,
createInitialState,
};
return game.value;
}
registry.register('setup', setup);
registration.add('setup', async function () {
const { context } = this;
while (true) {
const currentPlayer = context.value.currentPlayer;
const turnNumber = context.value.turn + 1;
const turnOutput = await this.run<{ winner: WinnerType }>(`turn ${currentPlayer} ${turnNumber}`);
if (!turnOutput.success) throw new Error(turnOutput.error);
async function turn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) {
const {player, row, col} = await game.prompt(
'play <player> <row:number> <col:number>',
(command: Command) => {
const [player, row, col] = command.params as [PlayerType, number, number];
context.produce(state => {
state.winner = turnOutput.result.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.turn = turnNumber;
}
});
if (context.value.winner) break;
}
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
} else if (!isValidMove(row, col)) {
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
} else if (isCellOccupied(game, row, col)) {
throw `Cell (${row}, ${col}) is already occupied.`;
} else {
return { player, row, col };
}
},
game.value.currentPlayer
);
return context.value;
});
placePiece(game, row, col, turnPlayer);
registration.add('reset', async function () {
const { context } = this;
context.produce(state => {
state.parts = {};
state.board.childIds = [];
state.board.partMap = {};
state.currentPlayer = 'X';
state.winner = null;
state.turn = 0;
});
// 重启主循环
return this.run('setup');
});
const winner = checkWinner(game);
if (winner) return { winner };
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
registration.add('turn <player> <turn:number>', async function (cmd) {
const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number];
return { winner: null };
}
const turnCommand = registry.register('turn <player:string> <turnNumber:int>', turn);
const playCmd = await this.prompt(
'play <player> <row:number> <col:number>',
(command) => {
const [player, row, col] = command.params as [PlayerType, number, number];
function isValidMove(row: number, col: number): boolean {
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
}
if (player !== turnPlayer) {
return `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) {
return `Invalid position: (${row}, ${col}).`;
}
const state = this.context.value;
const partId = state.board.partMap[`${row},${col}`];
if (partId) {
return `Cell (${row}, ${col}) is already occupied.`;
}
return null;
},
);
const [player, row, col] = playCmd.params as [PlayerType, number, number];
export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean {
return isCellOccupiedUtil(host.value.parts, 'board', [row, col]);
placePiece(this.context, row, col, turnPlayer);
const winner = checkWinner(this.context);
if (winner) return { winner };
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
return { winner: null };
});
export function isCellOccupied(host: MutableSignal<TicTacToeState>, row: number, col: number): boolean {
return !!host.value.board.partMap[`${row},${col}`];
}
export function hasWinningLine(positions: number[][]): boolean {
return WINNING_LINES.some(line =>
line.every(([r, c]) =>
positions.some(([pr, pc]) => pr === r && pc === c)
)
);
return WINNING_LINES.some(line =>
line.every(([r, c]) =>
positions.some(([pr, pc]) => pr === r && pc === c),
),
);
}
export function checkWinner(host: TicTacToeGame): WinnerType {
const parts = host.value.parts;
const partsArray = Object.values(parts);
export function checkWinner(host: MutableSignal<TicTacToeState>): WinnerType {
const parts = host.value.parts;
const xPositions = partsArray.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
const oPositions = partsArray.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
const xPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
const oPositions = Object.values(parts).filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
if (hasWinningLine(xPositions)) return 'X';
if (hasWinningLine(oPositions)) return 'O';
if (partsArray.length >= MAX_TURNS) return 'draw';
if (hasWinningLine(xPositions)) return 'X';
if (hasWinningLine(oPositions)) return 'O';
if (Object.keys(parts).length >= MAX_TURNS) return 'draw';
return null;
return null;
}
export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) {
const board = host.value.board;
const moveNumber = Object.keys(host.value.parts).length + 1;
const piece = createPart<{ player: PlayerType }>(
{ regionId: 'board', position: [row, col], player },
`piece-${player}-${moveNumber}`
);
host.produce((state: TicTacToeState) => {
state.parts[piece.id] = piece;
board.childIds.push(piece.id);
board.partMap[`${row},${col}`] = piece.id;
});
export function placePiece(host: MutableSignal<TicTacToeState>, row: number, col: number, player: PlayerType) {
const moveNumber = Object.keys(host.value.parts).length + 1;
const piece: TicTacToePart = {
id: `piece-${player}-${moveNumber}`,
regionId: 'board',
position: [row, col],
player,
};
host.produce(state => {
state.parts[piece.id] = piece;
state.board.childIds.push(piece.id);
state.board.partMap[`${row},${col}`] = piece.id;
});
}
export const gameModule = {
registry,
createInitialState,
};
/** 命令构建器:类型安全地生成命令字符串 */
export const commands = {
play: (player: PlayerType, row: number, col: number) => {
return `play ${player} ${row} ${col}`;
},
};
play: (player: PlayerType, row: number, col: number) => `play ${player} ${row} ${col}`,
turn: (player: PlayerType, turn: number) => `turn ${player} ${turn}`,
} as const;

View File

@ -2,6 +2,7 @@ import Phaser from 'phaser';
import type {TicTacToeState, TicTacToePart} from '@/game/tic-tac-toe';
import { GameHostScene } from 'boardgame-phaser';
import { spawnEffect, type Spawner } from 'boardgame-phaser';
import type { ReadonlySignal } from '@preact/signals-core';
import {commands} from "@/game/tic-tac-toe";
const CELL_SIZE = 120;
@ -25,9 +26,9 @@ export class GameScene extends GameHostScene<TicTacToeState> {
this.gridGraphics = this.add.graphics();
this.drawGrid();
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this)));
this.disposables.add(spawnEffect(new TicTacToePartSpawner(this, this.gameHost.state)));
this.addEffect(() => {
this.watch(() => {
const winner = this.state.winner;
if (winner) {
this.showWinner(winner);
@ -37,7 +38,7 @@ export class GameScene extends GameHostScene<TicTacToeState> {
}
});
this.addEffect(() => {
this.watch(() => {
const currentPlayer = this.state.currentPlayer;
this.updateTurnText(currentPlayer);
});
@ -162,10 +163,10 @@ export class GameScene extends GameHostScene<TicTacToeState> {
}
class TicTacToePartSpawner implements Spawner<TicTacToePart, Phaser.GameObjects.Text> {
constructor(public readonly scene: GameScene) {}
constructor(public readonly scene: GameScene, public readonly state: ReadonlySignal<TicTacToeState>) {}
*getData() {
for (const part of Object.values(this.scene.state.parts)) {
for (const part of Object.values(this.state.value.parts)) {
yield part;
}
}

View File

@ -14,13 +14,7 @@ export default function App<TState extends Record<string, unknown>>(props: { gam
const scene = useComputed(() => new props.gameScene());
const handleReset = async () => {
gameHost.value.gameHost.setup('setup').then(result => {
if(!result.success) {
console.error(result.error);
}else{
console.log('Game finished!', result.result);
}
});
gameHost.value.gameHost.setup('setup');
};
const label = useComputed(() => gameHost.value.gameHost.status.value === 'running' ? 'Restart' : 'Start');

View File

@ -26,9 +26,6 @@ importers:
boardgame-phaser:
specifier: workspace:*
version: link:../framework
inline-schema:
specifier: link:../../../inline-schema
version: link:../../../inline-schema
mutative:
specifier: ^1.3.0
version: 1.3.0