boardgame-core/src/samples/boop/index.ts

334 lines
11 KiB
TypeScript
Raw Normal View History

2026-04-02 16:53:17 +08:00
import {createGameCommandRegistry, Part, Entity, entity, RegionEntity} from '@/index';
const BOARD_SIZE = 6;
const MAX_PIECES_PER_PLAYER = 8;
const WIN_LENGTH = 3;
const DIRECTIONS = [
[-1, -1], [-1, 0], [-1, 1],
[0, -1], [0, 1],
[1, -1], [1, 0], [1, 1],
];
export type PlayerType = 'white' | 'black';
export type PieceType = 'kitten' | 'cat';
export type WinnerType = PlayerType | 'draw' | null;
type BoopPart = Part & { player: PlayerType; pieceType: PieceType };
export function createInitialState() {
return {
board: new RegionEntity('board', {
id: 'board',
axes: [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
],
children: [],
}),
parts: [] as Entity<BoopPart>[],
currentPlayer: 'white' as PlayerType,
winner: null as WinnerType,
whiteKittensInSupply: MAX_PIECES_PER_PLAYER,
blackKittensInSupply: MAX_PIECES_PER_PLAYER,
whiteCatsInSupply: 0,
blackCatsInSupply: 0,
};
}
export type BoopState = ReturnType<typeof createInitialState>;
const registration = createGameCommandRegistry<BoopState>();
export const registry = registration.registry;
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 maxRetries = 50;
let retries = 0;
while (retries < maxRetries) {
retries++;
const playCmd = await this.prompt('play <player> <row:number> <col:number>');
const [player, row, col] = playCmd.params as [PlayerType, number, number];
if (player !== turnPlayer) continue;
if (!isValidMove(row, col)) continue;
if (isCellOccupied(this.context, row, col)) continue;
const state = this.context.value;
const kittensInSupply = player === 'white' ? state.whiteKittensInSupply : state.blackKittensInSupply;
if (kittensInSupply <= 0) continue;
placeKitten(this.context, row, col, turnPlayer);
applyBoops(this.context, row, col, 'kitten');
const graduatedRows = checkGraduation(this.context, turnPlayer);
if (graduatedRows.length > 0) {
processGraduation(this.context, turnPlayer, graduatedRows);
}
const winner = checkWinner(this.context);
if (winner) return { winner };
return { winner: null };
}
throw new Error('Too many invalid attempts');
});
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: Entity<BoopState>) {
return host.value.board;
}
export function isCellOccupied(host: Entity<BoopState>, row: number, col: number): boolean {
const board = getBoardRegion(host);
return board.partsMap.value[`${row},${col}`] !== undefined;
}
export function getPartAt(host: Entity<BoopState>, row: number, col: number): Entity<BoopPart> | null {
const board = getBoardRegion(host);
return (board.partsMap.value[`${row},${col}`] as Entity<BoopPart> | undefined) || null;
}
export function placeKitten(host: Entity<BoopState>, row: number, col: number, player: PlayerType) {
const board = getBoardRegion(host);
const moveNumber = host.value.parts.length + 1;
const piece: BoopPart = {
id: `piece-${player}-${moveNumber}`,
region: board,
position: [row, col],
player,
pieceType: 'kitten',
};
host.produce(state => {
const e = entity(piece.id, piece);
state.parts.push(e);
if (player === 'white') state.whiteKittensInSupply--;
else state.blackKittensInSupply--;
board.produce(draft => {
draft.children.push(e);
});
});
}
export function applyBoops(host: Entity<BoopState>, placedRow: number, placedCol: number, placedType: PieceType) {
const board = getBoardRegion(host);
const partsMap = board.partsMap.value;
const piecesToBoop: { part: Entity<BoopPart>; dr: number; dc: number }[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
const [r, c] = part.value.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.value.pieceType === 'cat';
if (booperIsKitten && targetIsCat) continue;
piecesToBoop.push({ part, dr, dc });
}
}
for (const { part, dr, dc } of piecesToBoop) {
const [r, c] = part.value.position;
const newRow = r + dr;
const newCol = c + dc;
if (newRow < 0 || newRow >= BOARD_SIZE || newCol < 0 || newCol >= BOARD_SIZE) {
removePieceFromBoard(host, part);
const player = part.value.player;
host.produce(state => {
if (player === 'white') state.whiteKittensInSupply++;
else state.blackKittensInSupply++;
});
continue;
}
if (isCellOccupied(host, newRow, newCol)) continue;
part.produce(p => {
p.position = [newRow, newCol];
});
}
}
export function removePieceFromBoard(host: Entity<BoopState>, part: Entity<BoopPart>) {
const board = getBoardRegion(host);
host.produce(state => {
state.parts = state.parts.filter(p => p.id !== part.id);
board.produce(draft => {
draft.children = draft.children.filter(p => p.id !== part.id);
});
});
}
export function checkGraduation(host: Entity<BoopState>, player: PlayerType): number[][][] {
const parts = host.value.parts.filter(p => p.value.player === player && p.value.pieceType === 'kitten');
const positions = parts.map(p => p.value.position);
const winningLines: number[][][] = [];
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) {
line.push([r, c + i]);
}
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) {
winningLines.push(line);
}
}
}
for (let c = 0; c < BOARD_SIZE; c++) {
for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) {
line.push([r + i, c]);
}
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) {
winningLines.push(line);
}
}
}
for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) {
for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) {
line.push([r + i, c + i]);
}
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) {
winningLines.push(line);
}
}
}
for (let r = WIN_LENGTH - 1; r < BOARD_SIZE; r++) {
for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) {
line.push([r - i, c + i]);
}
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) {
winningLines.push(line);
}
}
}
return winningLines;
}
export function processGraduation(host: Entity<BoopState>, 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 board = getBoardRegion(host);
const partsMap = board.partsMap.value;
const partsToRemove: Entity<BoopPart>[] = [];
for (const key in partsMap) {
const part = partsMap[key] as Entity<BoopPart>;
if (part.value.player === player && part.value.pieceType === 'kitten' && allPositions.has(`${part.value.position[0]},${part.value.position[1]}`)) {
partsToRemove.push(part);
}
}
for (const part of partsToRemove) {
removePieceFromBoard(host, part);
}
const count = partsToRemove.length;
host.produce(state => {
const catsInSupply = player === 'white' ? state.whiteCatsInSupply : state.blackCatsInSupply;
if (player === 'white') state.whiteCatsInSupply = catsInSupply + count;
else state.blackCatsInSupply = catsInSupply + count;
});
}
export function checkWinner(host: Entity<BoopState>): WinnerType {
for (const player of ['white', 'black'] as PlayerType[]) {
const parts = host.value.parts.filter(p => p.value.player === player && p.value.pieceType === 'cat');
const positions = parts.map(p => p.value.position);
if (hasWinningLine(positions)) return player;
}
const totalParts = host.value.parts.length;
const whiteParts = host.value.parts.filter(p => p.value.player === 'white').length;
const blackParts = host.value.parts.filter(p => p.value.player === 'black').length;
if (whiteParts >= MAX_PIECES_PER_PLAYER && blackParts >= MAX_PIECES_PER_PLAYER) {
return 'draw';
}
return null;
}
export function hasWinningLine(positions: number[][]): boolean {
for (let r = 0; r < BOARD_SIZE; r++) {
for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) line.push([r, c + i]);
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true;
}
}
for (let c = 0; c < BOARD_SIZE; c++) {
for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) line.push([r + i, c]);
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true;
}
}
for (let r = 0; r <= BOARD_SIZE - WIN_LENGTH; r++) {
for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) line.push([r + i, c + i]);
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true;
}
}
for (let r = WIN_LENGTH - 1; r < BOARD_SIZE; r++) {
for (let c = 0; c <= BOARD_SIZE - WIN_LENGTH; c++) {
const line = [];
for (let i = 0; i < WIN_LENGTH; i++) line.push([r - i, c + i]);
if (line.every(([lr, lc]) => positions.some(([pr, pc]) => pr === lr && pc === lc))) return true;
}
}
return false;
}