diff --git a/packages/boop-game/src/game.ts b/packages/boop-game/src/game.ts new file mode 100644 index 0000000..92050b3 --- /dev/null +++ b/packages/boop-game/src/game.ts @@ -0,0 +1 @@ +export * from 'boardgame-core/samples/boop'; \ No newline at end of file diff --git a/packages/boop-game/src/game/commands.ts b/packages/boop-game/src/game/commands.ts deleted file mode 100644 index aaccb69..0000000 --- a/packages/boop-game/src/game/commands.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { - BOARD_SIZE, - BoopState, - BoopPart, - PieceType, - PlayerType, - WinnerType, - WIN_LENGTH, - MAX_PIECES_PER_PLAYER, - BoopGame, prompts, -} from "./data"; -import {createGameCommandRegistry, Command, moveToRegion} from "boardgame-core"; -import { - findPartAtPosition, - findPartInRegion, - getLineCandidates, - getNeighborPositions, - isCellOccupied, - isInBounds -} from "./utils"; - -export const registry = createGameCommandRegistry(); - -/** - * 放置棋子到棋盘 - */ -async function handlePlace(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 place = registry.register( 'place ', handlePlace); - -/** - * 执行boop - 推动周围棋子 - */ -async function handleBoop(game: BoopGame, row: number, col: number, type: PieceType) { - const booped: string[] = []; - - const toRemove = new Set(); - 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 - toRemove.add(part.id); - booped.push(part.id); - moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]); - } else if (!isCellOccupied(state, newRow, newCol)) { - // 新位置为空,移动过去 - booped.push(part.id); - moveToRegion(part, state.regions.board, state.regions.board, [newRow, newCol]); - } - // 如果新位置被占用,则不移动(两个棋子都保持原位) - } - }); - await game.produceAsync((state: BoopState) => { - // 移除被吃掉的棋子 - for (const partId of toRemove) { - const part = state.pieces[partId]; - moveToRegion(part, state.regions.board, state.regions[part.player]); - } - }); - - return { booped }; -} -const boop = registry.register('boop ', handleBoop); - -/** - * 检查是否有玩家获胜(三个猫连线) - */ -async function handleCheckWin(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 checkWin = registry.register('check-win', handleCheckWin); - -/** - * 检查并执行小猫升级(三个小猫连线变成猫) - */ -async function handleCheckGraduates(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: 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 checkGraduates = registry.register('check-graduates', handleCheckGraduates); - -export async function start(game: BoopGame) { - while (true) { - const currentPlayer = game.value.currentPlayer; - const { winner } = await turn(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; -} - -async function handleCheckFullBoard(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( - prompts.graduate, - (player, row, col) => { - 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]); - }); -} -const checkFullBoard = registry.register('check-full-board', handleCheckFullBoard); - -async function handleTurn(game: BoopGame, turnPlayer: PlayerType) { - const {row, col, type} = await game.prompt( - prompts.play, - (player, row, col, type?) => { - 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 place(game, row, col, turnPlayer, pieceType); - await boop(game, row, col, pieceType); - const winner = await checkWin(game); - if(winner) return { winner: winner as WinnerType }; - - await checkGraduates(game); - await handleCheckFullBoard(game, turnPlayer); - return { winner: null }; -} -const turn = registry.register('turn ', handleTurn); \ No newline at end of file diff --git a/packages/boop-game/src/game/data.ts b/packages/boop-game/src/game/data.ts deleted file mode 100644 index 737e971..0000000 --- a/packages/boop-game/src/game/data.ts +++ /dev/null @@ -1,59 +0,0 @@ -import parts from './parts.csv'; -import {createRegion, moveToRegion, Region, createPromptDef} 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; -export const prompts = { - play: createPromptDef<[PlayerType, number, number, PieceType?]>('play [type:string]'), - graduate: createPromptDef<[PlayerType, number, number]>('graduate '), -} - -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; - - // 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 deleted file mode 100644 index 5800496..0000000 --- a/packages/boop-game/src/game/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -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 deleted file mode 100644 index 79a5efd..0000000 --- a/packages/boop-game/src/game/parts.csv +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index bef8717..0000000 --- a/packages/boop-game/src/game/parts.csv.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -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 deleted file mode 100644 index c8292b3..0000000 --- a/packages/boop-game/src/game/rules.md +++ /dev/null @@ -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:** 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 deleted file mode 100644 index 570ec8c..0000000 --- a/packages/boop-game/src/game/utils.ts +++ /dev/null @@ -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; -} \ No newline at end of file diff --git a/packages/sample-game/src/game/tic-tac-toe.ts b/packages/sample-game/src/game/tic-tac-toe.ts index b7a4bb2..d777be5 100644 --- a/packages/sample-game/src/game/tic-tac-toe.ts +++ b/packages/sample-game/src/game/tic-tac-toe.ts @@ -1,137 +1 @@ -import { - createGameCommandRegistry, Part, createRegion, - IGameContext, createRegionAxis, GameModule, - createPromptDef -} 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]], -]; - -export type PlayerType = 'X' | 'O'; -export type WinnerType = PlayerType | 'draw' | null; -export type TicTacToePart = Part<{ player: PlayerType }>; -export type TicTacToeState = ReturnType; -export type TicTacToeGame = IGameContext; -export const prompts = { - play: createPromptDef<[PlayerType, number, number]>('play '), -} - -export function createInitialState() { - return { - board: createRegion('board', [ - createRegionAxis('x', 0, BOARD_SIZE - 1), - createRegionAxis('y', 0, BOARD_SIZE - 1), - ]), - parts: {} as Record, - currentPlayer: 'X' as PlayerType, - winner: null as WinnerType, - turn: 0, - }; -} -export const registry = createGameCommandRegistry(); - -export async function start(game: TicTacToeGame) { - while (true) { - const currentPlayer = game.value.currentPlayer; - const turnNumber = game.value.turn + 1; - const turnOutput = await turn(game, currentPlayer, turnNumber); - - game.produce((state: TicTacToeState) => { - state.winner = turnOutput.winner; - if (!state.winner) { - state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X'; - state.turn = turnNumber; - } - }); - if (game.value.winner) break; - } - - return game.value; -} - -async function handleTurn(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { - const {player, row, col} = await game.prompt( - prompts.play, - (player, row, col) => { - 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 - ); - - placePiece(game, row, col, turnPlayer); - - const winner = checkWinner(game); - if (winner) return { winner }; - if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType }; - - return { winner: null }; -} -const turn = registry.register('turn ', handleTurn); - -function isValidMove(row: number, col: number): boolean { - return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE; -} - -export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean { - return host.value.board.partMap[`${row},${col}`] !== undefined; -} - -export function hasWinningLine(positions: number[][]): boolean { - 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); - - 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 (partsArray.length >= MAX_TURNS) return 'draw'; - - 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 = { - regionId: 'board', position: [row, col], player, - id: `piece-${player}-${moveNumber}` - }; - host.produce((state: TicTacToeState) => { - state.parts[piece.id] = piece; - board.childIds.push(piece.id); - board.partMap[`${row},${col}`] = piece.id; - }); -} - -export const gameModule: GameModule = { - registry, - createInitialState, - start -}; \ No newline at end of file +export * from "boardgame-core/samples/tic-tac-toe"; \ No newline at end of file diff --git a/packages/sample-game/src/main.tsx b/packages/sample-game/src/main.tsx index 7ef9cb0..ceaca85 100644 --- a/packages/sample-game/src/main.tsx +++ b/packages/sample-game/src/main.tsx @@ -1,6 +1,6 @@ import { h } from 'preact'; import { GameUI } from 'boardgame-phaser'; -import { gameModule } from './game/tic-tac-toe'; +import * as gameModule from './game/tic-tac-toe'; import './style.css'; import App from "@/ui/App"; import {GameScene} from "@/scenes/GameScene";