From c85fdb604ccd3c68c9cb4ad69f4da77f037d5945 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sat, 4 Apr 2026 22:52:54 +0800 Subject: [PATCH] chore: update game implementation for boardgame-core --- packages/boop-game/src/game/boop.ts | 434 ------------------ packages/boop-game/src/game/commands.ts | 242 ++++++++++ packages/boop-game/src/game/data.ts | 55 +++ packages/boop-game/src/game/index.ts | 2 + packages/boop-game/src/game/parts.csv | 6 + packages/boop-game/src/game/parts.csv.d.ts | 8 + packages/boop-game/src/game/rules.md | 66 +++ packages/boop-game/src/game/utils.ts | 76 +++ packages/boop-game/src/main.tsx | 4 +- .../boop-game/src/scenes/BoardRenderer.ts | 2 +- packages/boop-game/src/scenes/GameScene.ts | 4 +- packages/sample-game/src/game/tic-tac-toe.ts | 223 ++++----- 12 files changed, 557 insertions(+), 565 deletions(-) delete mode 100644 packages/boop-game/src/game/boop.ts create mode 100644 packages/boop-game/src/game/commands.ts create mode 100644 packages/boop-game/src/game/data.ts create mode 100644 packages/boop-game/src/game/index.ts create mode 100644 packages/boop-game/src/game/parts.csv create mode 100644 packages/boop-game/src/game/parts.csv.d.ts create mode 100644 packages/boop-game/src/game/rules.md create mode 100644 packages/boop-game/src/game/utils.ts diff --git a/packages/boop-game/src/game/boop.ts b/packages/boop-game/src/game/boop.ts deleted file mode 100644 index 320a7f9..0000000 --- a/packages/boop-game/src/game/boop.ts +++ /dev/null @@ -1,434 +0,0 @@ -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>; - catPool: ReturnType>; - graduatedCount: number; // 已毕业但未放置的大猫数量 -}; - -type PlayerData = Record; - -export type BoopState = { - board: ReturnType; - pieces: Record; - 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, - currentPlayer: 'white' as PlayerType, - winner: null as WinnerType, - players: { - white: createPlayer('white'), - black: createPlayer('black'), - }, - }; -} - -export type BoopStateType = ReturnType; -const registration = createGameCommandRegistry(); -export const registry = registration.registry; - -export function getPlayer(host: MutableSignal, 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 ', async function(cmd) { - const [turnPlayer] = cmd.params as [PlayerType]; - - const playCmd = await this.prompt( - 'play [type:string]', - (command) => { - const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?]; - const pieceType = type === 'cat' ? 'cat' : 'kitten'; - - if (player !== turnPlayer) { - return `Invalid player: ${player}. Expected ${turnPlayer}.`; - } - if (!isValidMove(row, col)) { - return `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`; - } - if (isCellOccupied(this.context, row, col)) { - return `Cell (${row}, ${col}) is already occupied.`; - } - - const playerData = getPlayer(this.context, player); - 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 ', - (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) { - return host.value.board; -} - -export function isCellOccupied(host: MutableSignal, row: number, col: number): boolean { - return isCellOccupiedUtil(host.value.pieces, 'board', [row, col]); -} - -export function getPartAt(host: MutableSignal, row: number, col: number): BoopPart | null { - return getPartAtPosition(host.value.pieces, 'board', [row, col]) || null; -} - -export function placePiece(host: MutableSignal, row: number, col: number, player: PlayerType, pieceType: PieceType) { - const board = getBoardRegion(host); - const playerData = getPlayer(host, player); - - 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, 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, 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, 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 { - for (const [dr, dc] of DIRECTIONS) { - const minStart = -(WIN_LENGTH - 1); - for (let offset = minStart; offset <= 0; offset++) { - const startR = r + offset * dr; - const startC = c + offset * dc; - const endR = startR + (WIN_LENGTH - 1) * dr; - const endC = startC + (WIN_LENGTH - 1) * dc; - - if (startR < 0 || startR >= BOARD_SIZE || startC < 0 || startC >= BOARD_SIZE) continue; - if (endR < 0 || endR >= BOARD_SIZE || endC < 0 || endC >= BOARD_SIZE) continue; - - const line: number[][] = []; - for (let i = 0; i < WIN_LENGTH; i++) { - line.push([startR + i * dr, startC + i * dc]); - } - yield line; - } - } -} - -export function* allLines(): Generator { - const seen = new Set(); - for (let r = 0; r < BOARD_SIZE; r++) { - for (let c = 0; c < BOARD_SIZE; c++) { - for (const line of linesThrough(r, c)) { - const key = line.map(p => p.join(',')).join(';'); - if (!seen.has(key)) { - seen.add(key); - yield line; - } - } - } - } -} - -export function hasWinningLine(positions: number[][]): boolean { - const posSet = new Set(positions.map(p => `${p[0]},${p[1]}`)); - for (const line of allLines()) { - if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) return true; - } - return false; -} - -export function checkGraduation(host: MutableSignal, player: PlayerType): number[][][] { - const pieces = host.value.pieces; - const piecesArray = Object.values(pieces); - const posSet = new Set(); - - for (const part of piecesArray) { - if (part.player === player && part.pieceType === 'kitten') { - posSet.add(`${part.position[0]},${part.position[1]}`); - } - } - - const winningLines: number[][][] = []; - for (const line of allLines()) { - if (line.every(([lr, lc]) => posSet.has(`${lr},${lc}`))) { - winningLines.push(line); - } - } - return winningLines; -} - -export function processGraduation(host: MutableSignal, player: PlayerType, lines: number[][][]) { - const allPositions = new Set(); - for (const line of lines) { - for (const [r, c] of line) { - allPositions.add(`${r},${c}`); - } - } - - const 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, player: PlayerType): number { - const pieces = host.value.pieces; - return Object.values(pieces).filter(p => p.player === player).length; -} - -export function checkWinner(host: MutableSignal): WinnerType { - const pieces = host.value.pieces; - const piecesArray = Object.values(pieces); - - for (const player of ['white', 'black'] as PlayerType[]) { - const positions = piecesArray - .filter(p => p.player === player && p.pieceType === 'cat') - .map(p => p.position); - if (hasWinningLine(positions)) return player; - } - - return null; -} - -// 命令构建器 -export const commands = { - play: (player: PlayerType, row: number, col: number, type?: PieceType) => - `play ${player} ${row} ${col}${type ? ` ${type}` : ''}`, - turn: (player: PlayerType) => `turn ${player}`, - graduate: (row: number, col: number) => `graduate ${row} ${col}`, -} as const; - -// 导出游戏模块 -export const gameModule = { - createInitialState, - registry, - commands, -}; diff --git a/packages/boop-game/src/game/commands.ts b/packages/boop-game/src/game/commands.ts new file mode 100644 index 0000000..1d7d117 --- /dev/null +++ b/packages/boop-game/src/game/commands.ts @@ -0,0 +1,242 @@ +import { + BOARD_SIZE, + BoopState, + PieceType, + PlayerType, + WinnerType, + WIN_LENGTH, + MAX_PIECES_PER_PLAYER, BoopGame +} from "./data"; +import {createGameCommandRegistry} from "@/core/game"; +import {moveToRegion} from "@/core/region"; +import { + findPartAtPosition, + findPartInRegion, + getLineCandidates, + getNeighborPositions, + isCellOccupied, + isInBounds +} from "./utils"; + +export const registry = createGameCommandRegistry(); + +/** + * 放置棋子到棋盘 + */ +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(`No ${type} available in ${player}'s supply`); + } + + const partId = part.id; + + await game.produceAsync(state => { + // 将棋子从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 ', place); + +/** + * 执行boop - 推动周围棋子 + */ +async function boop(game: BoopGame, row: number, col: number, type: PieceType) { + const booped: string[] = []; + + await game.produceAsync(state => { + // 按照远离放置位置的方向推动 + 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 ', 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(); + 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 => { + 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 turnOutput = await turnCommand(game, currentPlayer); + if (!turnOutput.success) throw new Error(turnOutput.error); + + await game.produceAsync(state => { + state.winner = turnOutput.result.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 => p.player === turnPlayer && p.regionId === 'board' + ); + if(playerPieces.length < MAX_PIECES_PER_PLAYER || game.value.winner !== null){ + return; + } + + const partId = await game.prompt( + 'choose ', + (command) => { + const [player, row, col] = command.params as [PlayerType, number, number]; + if (player !== turnPlayer) { + throw `Invalid player: ${player}. Expected ${turnPlayer}.`; + } + if (!isInBounds(row, col)) { + throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`; + } + + const part = findPartAtPosition(game, row, col); + if (!part || part.player !== turnPlayer) { + throw `No ${player} piece at (${row}, ${col}).`; + } + + return part.id; + } + ); + + await game.produceAsync(state => { + 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 [type:string]', + (command) => { + const [player, row, col, type] = command.params as [PlayerType, number, number, PieceType?]; + const pieceType = type === 'cat' ? 'cat' : 'kitten'; + + if (player !== turnPlayer) { + throw `Invalid player: ${player}. Expected ${turnPlayer}.`; + } + if (!isInBounds(row, col)) { + throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`; + } + if (isCellOccupied(game, row, col)) { + throw `Cell (${row}, ${col}) is already occupied.`; + } + + const found = findPartInRegion(game, player, pieceType); + if (!found) { + throw `No ${pieceType}s left in ${player}'s supply.`; + } + 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.success) return { winner: winner.result as WinnerType }; + + await checkGraduatesCommand(game); + await checkFullBoard(game, turnPlayer); + return { winner: null }; +} +const turnCommand = registry.register('turn ', turn); \ No newline at end of file diff --git a/packages/boop-game/src/game/data.ts b/packages/boop-game/src/game/data.ts new file mode 100644 index 0000000..fcb33ef --- /dev/null +++ b/packages/boop-game/src/game/data.ts @@ -0,0 +1,55 @@ +import parts from './parts.csv'; +import {createRegion, moveToRegion, Region} from "@/core/region"; +import {createPartsFromTable} from "@/core/part-factory"; +import {Part} from "@/core/part"; +import {IGameContext} from "@/core/game"; + +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; + +export function createInitialState() { + const pieces = createPartsFromTable( + parts, + (item, index) => `${item.player}-${item.type}-${index + 1}`, + (item) => item.count + ) as Record; + + // 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, + pieces, + currentPlayer: 'white' as PlayerType, + winner: null as WinnerType, + }; +} +export type BoopState = ReturnType; +export type BoopGame = IGameContext; \ No newline at end of file diff --git a/packages/boop-game/src/game/index.ts b/packages/boop-game/src/game/index.ts new file mode 100644 index 0000000..5800496 --- /dev/null +++ b/packages/boop-game/src/game/index.ts @@ -0,0 +1,2 @@ +export * from './data'; +export * from './commands'; \ No newline at end of file diff --git a/packages/boop-game/src/game/parts.csv b/packages/boop-game/src/game/parts.csv new file mode 100644 index 0000000..79a5efd --- /dev/null +++ b/packages/boop-game/src/game/parts.csv @@ -0,0 +1,6 @@ +type,player,count +string,string,int +kitten,white,8 +kitten,black,8 +cat,white,8 +cat,black,8 \ No newline at end of file diff --git a/packages/boop-game/src/game/parts.csv.d.ts b/packages/boop-game/src/game/parts.csv.d.ts new file mode 100644 index 0000000..bef8717 --- /dev/null +++ b/packages/boop-game/src/game/parts.csv.d.ts @@ -0,0 +1,8 @@ +type Table = { + type: string; + player: string; + count: number; + }[]; + +declare const data: Table; +export default data; diff --git a/packages/boop-game/src/game/rules.md b/packages/boop-game/src/game/rules.md new file mode 100644 index 0000000..c8292b3 --- /dev/null +++ b/packages/boop-game/src/game/rules.md @@ -0,0 +1,66 @@ +# 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:** 15–20 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. diff --git a/packages/boop-game/src/game/utils.ts b/packages/boop-game/src/game/utils.ts new file mode 100644 index 0000000..e101f42 --- /dev/null +++ b/packages/boop-game/src/game/utils.ts @@ -0,0 +1,76 @@ +import { + BOARD_SIZE, + BoopGame, + BoopPart, + BoopState, + PieceType, + PlayerType, + RegionType, + WIN_LENGTH +} from "@/samples/boop/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 => 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; +} \ No newline at end of file diff --git a/packages/boop-game/src/main.tsx b/packages/boop-game/src/main.tsx index 7edaef3..1a7c105 100644 --- a/packages/boop-game/src/main.tsx +++ b/packages/boop-game/src/main.tsx @@ -1,13 +1,13 @@ import { h } from 'preact'; import { GameUI } from 'boardgame-phaser'; -import { gameModule } from './game/boop'; +import * as boop from './game'; import './style.css'; import App from "@/ui/App"; import {GameScene} from "@/scenes/GameScene"; const ui = new GameUI({ container: document.getElementById('ui-root')!, - root: , + root: , }); ui.mount(); diff --git a/packages/boop-game/src/scenes/BoardRenderer.ts b/packages/boop-game/src/scenes/BoardRenderer.ts index d040638..d452fa4 100644 --- a/packages/boop-game/src/scenes/BoardRenderer.ts +++ b/packages/boop-game/src/scenes/BoardRenderer.ts @@ -1,5 +1,5 @@ import Phaser from 'phaser'; -import type { BoopState, PlayerType } from '@/game/boop'; +import type { BoopState, PlayerType } from '@/game'; import type { ReadonlySignal } from '@preact/signals-core'; const BOARD_SIZE = 6; diff --git a/packages/boop-game/src/scenes/GameScene.ts b/packages/boop-game/src/scenes/GameScene.ts index ac4989c..13d5fd6 100644 --- a/packages/boop-game/src/scenes/GameScene.ts +++ b/packages/boop-game/src/scenes/GameScene.ts @@ -1,6 +1,6 @@ -import type { BoopState } from '@/game/boop'; +import type { BoopState } from '@/game'; import { GameHostScene } from 'boardgame-phaser'; -import { commands } from '@/game/boop'; +import { commands } from '@/game'; import { BoardRenderer } from './BoardRenderer'; import { createPieceSpawner } from './PieceSpawner'; import { SupplyUI } from './SupplyUI'; diff --git a/packages/sample-game/src/game/tic-tac-toe.ts b/packages/sample-game/src/game/tic-tac-toe.ts index 97629fa..23f8fdf 100644 --- a/packages/sample-game/src/game/tic-tac-toe.ts +++ b/packages/sample-game/src/game/tic-tac-toe.ts @@ -1,161 +1,132 @@ import { - createGameCommandRegistry, - type Part, - createRegion, - type MutableSignal, - type GameModule, -} from 'boardgame-core'; + createGameCommandRegistry, Part, createRegion, createPart, isCellOccupied as isCellOccupiedUtil, + IGameContext +} from '@/index'; 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'; export type WinnerType = PlayerType | 'draw' | null; -export type TicTacToePart = Part<{ player: PlayerType }>; +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, - 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, + currentPlayer: 'X' as PlayerType, + winner: null as WinnerType, + turn: 0, + }; } export type TicTacToeState = ReturnType; +export type TicTacToeGame = IGameContext; +export const registry = createGameCommandRegistry(); -const registration = createGameCommandRegistry(); -export const registry = registration.registry; +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); -export const gameModule: GameModule = { - registry, - createInitialState, -}; + game.produce(state => { + state.winner = turnOutput.result.winner; + if (!state.winner) { + state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X'; + state.turn = turnNumber; + } + }); + if (game.value.winner) break; + } -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); + return game.value; +} +registry.register('setup', setup); - 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; - } +async function turn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { + const {player, row, col} = await game.prompt( + 'play ', + (command) => { + const [player, row, col] = command.params as [PlayerType, number, number]; - return context.value; -}); + 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 + ); -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'); -}); + placePiece(game, row, col, turnPlayer); -registration.add('turn ', async function (cmd) { - const [turnPlayer, turnNumber] = cmd.params as [PlayerType, number]; + const winner = checkWinner(game); + if (winner) return { winner }; + if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType }; - const playCmd = await this.prompt( - 'play ', - (command) => { - const [player, row, col] = command.params as [PlayerType, number, number]; + return { winner: null }; +} +const turnCommand = registry.register('turn ', turn); - 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]; +function isValidMove(row: number, col: number): boolean { + return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; +} - 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, row: number, col: number): boolean { - return !!host.value.board.partMap[`${row},${col}`]; +export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean { + return isCellOccupiedUtil(host.value.parts, 'board', [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: MutableSignal): WinnerType { - const parts = host.value.parts; +export function checkWinner(host: TicTacToeGame): WinnerType { + const parts = host.value.parts; + const partsArray = Object.values(parts); - 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); + 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); - if (hasWinningLine(xPositions)) return 'X'; - if (hasWinningLine(oPositions)) return 'O'; - if (Object.keys(parts).length >= MAX_TURNS) return 'draw'; + if (hasWinningLine(xPositions)) return 'X'; + if (hasWinningLine(oPositions)) return 'O'; + if (partsArray.length >= MAX_TURNS) return 'draw'; - return null; + return null; } -export function placePiece(host: MutableSignal, 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 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 => { + state.parts[piece.id] = piece; + board.childIds.push(piece.id); + board.partMap[`${row},${col}`] = piece.id; + }); } - -/** 命令构建器:类型安全地生成命令字符串 */ -export const commands = { - play: (player: PlayerType, row: number, col: number) => `play ${player} ${row} ${col}`, - turn: (player: PlayerType, turn: number) => `turn ${player} ${turn}`, -} as const;