refactor: use boardgame-core's sample exports

This commit is contained in:
hypercross 2026-04-07 16:16:04 +08:00
parent cedb69e55e
commit deb8c91239
10 changed files with 3 additions and 604 deletions

View File

@ -0,0 +1 @@
export * from 'boardgame-core/samples/boop';

View File

@ -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<BoopState>();
/**
*
*/
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 <row:number> <col:number> <player> <type>', handlePlace);
/**
* boop -
*/
async function handleBoop(game: BoopGame, row: number, col: number, type: PieceType) {
const booped: string[] = [];
const toRemove = new Set<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
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 <row:number> <col:number> <type>', 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<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 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 <player>', handleTurn);

View File

@ -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<BoopPartMeta>;
export const prompts = {
play: createPromptDef<[PlayerType, number, number, PieceType?]>('play <player> <row:number> <col:number> [type:string]'),
graduate: createPromptDef<[PlayerType, number, number]>('graduate <player> <row:number> <col:number>'),
}
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,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<typeof createInitialState>;
export type TicTacToeGame = IGameContext<TicTacToeState>;
export const prompts = {
play: createPromptDef<[PlayerType, number, number]>('play <player> <row:number> <col:number>'),
}
export function createInitialState() {
return {
board: createRegion('board', [
createRegionAxis('x', 0, BOARD_SIZE - 1),
createRegionAxis('y', 0, BOARD_SIZE - 1),
]),
parts: {} as Record<string, TicTacToePart>,
currentPlayer: 'X' as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export const registry = createGameCommandRegistry<TicTacToeState>();
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 <player:string> <turnNumber:int>', 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<TicTacToeState> = {
registry,
createInitialState,
start
};
export * from "boardgame-core/samples/tic-tac-toe";

View File

@ -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";