chore: update game implementation for boardgame-core
This commit is contained in:
parent
99dc5e29e7
commit
c85fdb604c
|
|
@ -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<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,
|
||||
};
|
||||
|
|
@ -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<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(`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 <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 => {
|
||||
// 按照远离放置位置的方向推动
|
||||
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 => {
|
||||
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 <player> <row:number> <col:number>',
|
||||
(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 <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) {
|
||||
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 <player>', turn);
|
||||
|
|
@ -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<BoopPartMeta>;
|
||||
|
||||
export function createInitialState() {
|
||||
const pieces = createPartsFromTable(
|
||||
parts,
|
||||
(item, index) => `${item.player}-${item.type}-${index + 1}`,
|
||||
(item) => 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>;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './data';
|
||||
export * from './commands';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
type,player,count
|
||||
string,string,int
|
||||
kitten,white,8
|
||||
kitten,black,8
|
||||
cat,white,8
|
||||
cat,black,8
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
type Table = {
|
||||
type: string;
|
||||
player: string;
|
||||
count: number;
|
||||
}[];
|
||||
|
||||
declare const data: Table;
|
||||
export default data;
|
||||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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: <App gameModule={gameModule} gameScene={GameScene}/>,
|
||||
root: <App gameModule={boop} gameScene={GameScene}/>,
|
||||
});
|
||||
|
||||
ui.mount();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<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>();
|
||||
|
||||
const registration = createGameCommandRegistry<TicTacToeState>();
|
||||
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<TicTacToeState> = {
|
||||
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 <player> <row:number> <col:number>',
|
||||
(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 <player> <turn:number>', 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 <player> <row:number> <col:number>',
|
||||
(command) => {
|
||||
const [player, row, col] = command.params as [PlayerType, number, number];
|
||||
return { winner: null };
|
||||
}
|
||||
const turnCommand = registry.register('turn <player:string> <turnNumber:int>', 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<TicTacToeState>, 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<TicTacToeState>): 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<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 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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue