Compare commits

..
5 Commits
Author SHA1 Message Date
hyper f5df1c26dc refactor: remove registry from Boop and Regicide index files 2026-04-23 15:29:14 +08:00
hyper 2014162819 refactor: update Regicide commands and improve code style 2026-04-23 15:28:45 +08:00
hyper d932c2f5e2 refactor: remove Boop command registry and simplify exports
Remove the command registry from the Boop sample and export command
functions directly. This simplifies the command structure and replaces
dynamic imports with static imports.
2026-04-23 15:21:13 +08:00
hyper 1bd49c32e0 refactor: format Onitama commands and Tic-Tac-Toe imports 2026-04-23 15:18:37 +08:00
hyper b009189b9a Extract PromptContext from GameHost into separate module
The prompt system is now managed by a dedicated PromptContext instead of
being embedded in GameHost. This simplifies the API by removing
individual prompt signals and consolidating prompt logic into a reusable
context that can be shared between components.
2026-04-23 15:09:06 +08:00
12 changed files with 1050 additions and 1156 deletions
+30 -87
View File
@@ -1,100 +1,40 @@
import { ReadonlySignal, Signal } from "@preact/signals-core"; import { ReadonlySignal, Signal } from "@preact/signals-core";
import { CommandSchema, CommandRegistry, PromptEvent } from "@/utils/command"; import { createPromptContext } from "@/utils/command";
import { import { createGameContext, IGameContext } from "./game";
createGameCommandRegistry, import { PromptContext } from "@/utils/command/command-prompt";
createGameContext,
IGameContext,
PromptDef,
} from "./game";
export type GameHostStatus = "created" | "running" | "disposed"; export type GameHostStatus = "created" | "running" | "disposed";
export class GameHost< export class GameHost<TModule extends GameModule> {
TState extends Record<string, unknown>, readonly state: ReadonlySignal<GameState<TModule>>;
TResult = unknown,
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
> {
readonly state: ReadonlySignal<TState>;
readonly status: ReadonlySignal<GameHostStatus>; readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>; readonly prompts: PromptContext;
readonly activePromptPlayer: ReadonlySignal<string | null>;
readonly activePromptHint: ReadonlySignal<string | null>;
private _context: IGameContext<TState>; private _context: IGameContext<GameState<TModule>>;
private _start: (ctx: IGameContext<TState>) => Promise<TResult>; private _start: (
ctx: IGameContext<GameState<TModule>>,
) => Promise<GameResult<TModule>>;
private _status: Signal<GameHostStatus>; private _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>; private _createInitialState: () => GameState<TModule>;
private _activePromptPlayer: Signal<string | null>;
private _activePromptHint: Signal<string | null>;
private _createInitialState: () => TState;
private _eventListeners: Map<"start" | "dispose", Set<() => void>>; private _eventListeners: Map<"start" | "dispose", Set<() => void>>;
private _isDisposed = false; private _isDisposed = false;
constructor(public readonly gameModule: TModule) { constructor(public readonly gameModule: TModule) {
const { createInitialState, registry, start } = gameModule; const { createInitialState, start } = gameModule as unknown as GameModule<
GameState<TModule>,
GameResult<TModule>
>;
this._createInitialState = createInitialState; this._createInitialState = createInitialState;
this._eventListeners = new Map(); this._eventListeners = new Map();
this.prompts = createPromptContext();
const initialState = createInitialState(); const initialState = createInitialState();
this._context = createGameContext( this._context = createGameContext(this.prompts, initialState);
registry ?? createGameCommandRegistry(),
initialState,
);
this._start = start; this._start = start;
this.state = this._context._state; this.state = this._context._state;
this._status = new Signal<GameHostStatus>("created"); this._status = new Signal<GameHostStatus>("created");
this.status = this._status; this.status = this._status;
this._activePromptSchema = new Signal<CommandSchema | null>(null);
this.activePromptSchema = this._activePromptSchema;
this._activePromptPlayer = new Signal<string | null>(null);
this.activePromptPlayer = this._activePromptPlayer;
this._activePromptHint = new Signal<string | null>(null);
this.activePromptHint = this._activePromptHint;
this._setupPromptTracking();
}
private _setupPromptTracking() {
let currentPromptEvent: PromptEvent | null = null;
this._context._commands.on("prompt", (e) => {
currentPromptEvent = e as PromptEvent;
this._activePromptSchema.value = currentPromptEvent.schema;
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
this._activePromptHint.value = currentPromptEvent.hintText || null;
});
this._context._commands.on("promptEnd", () => {
currentPromptEvent = null;
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
});
// Initial state
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
}
tryInput(input: string): string | null {
if (this._isDisposed) {
return "GameHost is disposed";
}
return this._context._commands._tryCommit(input);
}
tryAnswerPrompt<TArgs extends any[]>(def: PromptDef<TArgs>, ...args: TArgs) {
return this._context._commands._tryCommit({
name: def.schema.name,
params: args,
options: {},
flags: {},
});
} }
/** /**
@@ -113,12 +53,12 @@ export class GameHost<
this._context._state.clearInterruptions(); this._context._state.clearInterruptions();
} }
start(seed?: number): Promise<TResult> { start(seed?: number): Promise<GameResult<TModule>> {
if (this._isDisposed) { if (this._isDisposed) {
throw new Error("GameHost is disposed"); throw new Error("GameHost is disposed");
} }
this._context._commands._cancel(); this.prompts.reset();
const initialState = this._createInitialState(); const initialState = this._createInitialState();
this._context._state.value = initialState as any; this._context._state.value = initialState as any;
@@ -139,7 +79,7 @@ export class GameHost<
} }
this._isDisposed = true; this._isDisposed = true;
this._context._commands._cancel(); this.prompts.reset();
this._status.value = "disposed"; this._status.value = "disposed";
// Emit dispose event BEFORE clearing listeners // Emit dispose event BEFORE clearing listeners
@@ -169,18 +109,21 @@ export class GameHost<
} }
export type GameModule< export type GameModule<
TState extends Record<string, unknown>, TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown, TResult = unknown,
> = { > = {
registry?: CommandRegistry<IGameContext<TState>>;
createInitialState: () => TState; createInitialState: () => TState;
start: (ctx: IGameContext<TState>) => Promise<TResult>; start: (ctx: IGameContext<TState>) => Promise<TResult>;
}; };
export type GameState<TModule extends GameModule> = ReturnType<
TModule["createInitialState"]
>;
export type GameResult<TModule extends GameModule> = Awaited<
ReturnType<TModule["start"]>
>;
export function createGameHost< export function createGameHost<TModule extends GameModule>(
TState extends Record<string, unknown>, gameModule: TModule,
TResult = unknown, ): GameHost<TModule> {
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
>(gameModule: TModule): GameHost<TState, TResult> {
return new GameHost(gameModule); return new GameHost(gameModule);
} }
+6 -46
View File
@@ -1,40 +1,29 @@
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal"; import { MutableSignal, mutableSignal } from "@/utils/mutable-signal";
import { import { CommandSchema, parseCommandSchema, PromptDef } from "@/utils/command";
Command,
CommandRegistry,
CommandResult,
CommandRunnerContextExport,
CommandSchema,
createCommandRegistry,
createCommandRunnerContext,
parseCommandSchema,
} from "@/utils/command";
import { PromptValidator } from "@/utils/command/command-runner"; import { PromptValidator } from "@/utils/command/command-runner";
import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng"; import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng";
import { PromptContext } from "@/utils/command/command-prompt";
export interface IGameContext<TState extends Record<string, unknown> = {}> { export interface IGameContext<TState extends Record<string, unknown> = {}> {
get value(): TState; get value(): TState;
get rng(): ReadonlyRNG; get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => undefined): void; produce(fn: (draft: TState) => undefined): void;
produceAsync(fn: (draft: TState) => undefined): Promise<void>; produceAsync(fn: (draft: TState) => undefined): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>;
runParsed<T>(command: Command): Promise<CommandResult<T>>;
prompt: <TResult, TArgs extends any[] = any[]>( prompt: <TResult, TArgs extends any[] = any[]>(
def: PromptDef<TArgs>, def: PromptDef<TArgs>,
validator: PromptValidator<TResult, TArgs>, validator: PromptValidator<TResult, TArgs>,
currentPlayer?: string | null, player?: string,
) => Promise<TResult>; ) => Promise<TResult>;
// test only // test only
_state: MutableSignal<TState>; _state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<TState>>;
_rng: RNG; _rng: RNG;
} }
export type IGameContextExport<TState extends Record<string, unknown> = {}> = export type IGameContextExport<TState extends Record<string, unknown> = {}> =
Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">; Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">;
export function createGameContext<TState extends Record<string, unknown> = {}>( export function createGameContext<TState extends Record<string, unknown> = {}>(
commandRegistry: CommandRegistry<IGameContext<TState>>, promptContext: PromptContext,
initialState?: TState | (() => TState), initialState?: TState | (() => TState),
): IGameContext<TState> { ): IGameContext<TState> {
const stateValue = const stateValue =
@@ -42,7 +31,7 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
? initialState() ? initialState()
: (initialState ?? ({} as TState)); : (initialState ?? ({} as TState));
const state = mutableSignal(stateValue); const state = mutableSignal(stateValue);
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any; const { prompt } = promptContext;
const context: IGameContext<TState> = { const context: IGameContext<TState> = {
get value(): TState { get value(): TState {
@@ -57,38 +46,15 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
produceAsync(fn: (draft: TState) => undefined) { produceAsync(fn: (draft: TState) => undefined) {
return state.produceAsync(fn); return state.produceAsync(fn);
}, },
run<T>(input: string) { prompt,
return commands.run<T>(input);
},
runParsed<T>(command: Command) {
return commands.runParsed<T>(command);
},
prompt(def, validator, currentPlayer) {
return commands.prompt(
def.schema,
validator,
def.hintText,
currentPlayer,
);
},
_state: state, _state: state,
_commands: commands,
_rng: new Mulberry32RNG(), _rng: new Mulberry32RNG(),
}; };
context._commands = commands = createCommandRunnerContext(
commandRegistry,
context,
);
return context; return context;
} }
export type PromptDef<TArgs extends any[] = any[]> = {
schema: CommandSchema;
hintText?: string;
};
export function createPromptDef<TArgs extends any[] = any[]>( export function createPromptDef<TArgs extends any[] = any[]>(
schema: CommandSchema | string, schema: CommandSchema | string,
hintText?: string, hintText?: string,
@@ -96,9 +62,3 @@ export function createPromptDef<TArgs extends any[] = any[]>(
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema; schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText }; return { schema, hintText };
} }
export function createGameCommandRegistry<
TState extends Record<string, unknown> = {},
>() {
return createCommandRegistry<IGameContext<TState>>();
}
+1 -1
View File
@@ -5,7 +5,7 @@
// Core types // Core types
export type { IGameContext } from "./core/game"; export type { IGameContext } from "./core/game";
export { createGameCommandRegistry, createPromptDef } from "./core/game"; export { createPromptDef } from "./core/game";
export type { GameHost, GameHostStatus, GameModule } from "./core/game-host"; export type { GameHost, GameHostStatus, GameModule } from "./core/game-host";
export { createGameHost } from "./core/game-host"; export { createGameHost } from "./core/game-host";
+22 -92
View File
@@ -1,101 +1,31 @@
import {BoopGame} from "@/samples/boop/types-extensions"; import { BoopGame } from "@/samples/boop/types-extensions";
import {PlayerType} from "@/samples/boop/types"; import { PlayerType } from "@/samples/boop/types";
import {turn} from "@/samples/boop/commands/turn"; import { turn } from "@/samples/boop/commands/turn";
import {createGameCommandRegistry} from "@/core/game"; import { boop } from "./boop";
import { checkFullBoard } from "./full-board";
export const registry = createGameCommandRegistry<BoopGame['value']>(); import { checkGraduates } from "./graduate";
import { place } from "./place";
/** import { checkWin } from "./win";
* 放置棋子到棋盘
*/
const placeCmd = registry.register({
schema: 'place <row:number> <col:number> <player> <type>',
run: async (game, row, col, player, type) => {
const {place} = await import('./place');
return place(game, row, col, player, type);
}
});
/**
* 执行boop - 推动周围棋子
*/
const boopCmd = registry.register({
schema: 'boop <row:number> <col:number> <type>',
run: async (game, row, col, type) => {
const {boop} = await import('./boop');
return boop(game, row, col, type);
}
});
/**
* 检查是否有玩家获胜(三个猫连线)
*/
const checkWinCmd = registry.register({
schema: 'check-win',
run: async (game) => {
const {checkWin} = await import('./win');
return checkWin(game);
}
});
/**
* 检查并执行小猫升级(三个小猫连线变成猫)
*/
const checkGraduatesCmd = registry.register({
schema: 'check-graduates',
run: async (game) => {
const {checkGraduates} = await import('./graduate');
return checkGraduates(game);
}
});
/**
* 检查并执行 8-piece 规则
*/
const checkFullBoardCmd = registry.register({
schema: 'check-full-board <player:string>',
run: async (game, player) => {
const {checkFullBoard} = await import('./full-board');
return checkFullBoard(game, player);
}
});
/**
* 处理一个回合
*/
const turnCmd = registry.register({
schema: 'turn <player:string>',
run: async (game, player) => {
const {turn} = await import('./turn');
return turn(game, player);
}
});
/** /**
* 启动游戏主循环 * 启动游戏主循环
*/ */
export async function start(game: BoopGame) { export async function start(game: BoopGame) {
while (true) { while (true) {
const currentPlayer = game.value.currentPlayer; const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer); const turnOutput = await turn(game, currentPlayer);
await game.produceAsync(state => { await game.produceAsync((state) => {
state.winner = turnOutput.winner; state.winner = turnOutput.winner;
if (!state.winner) { if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white'; state.currentPlayer =
} state.currentPlayer === "white" ? "black" : "white";
}); }
if (game.value.winner) break; });
} if (game.value.winner) break;
}
return game.value; return game.value;
} }
export { export { place, boop, checkWin, checkGraduates, checkFullBoard, turn };
placeCmd as place,
boopCmd as boop,
checkWinCmd as checkWin,
checkGraduatesCmd as checkGraduates,
checkFullBoardCmd as checkFullBoard,
turnCmd as turn
};
+26 -34
View File
@@ -1,49 +1,41 @@
// Types // Types
export type { export type {
PlayerType, PlayerType,
PieceType, PieceType,
WinnerType, WinnerType,
RegionType, RegionType,
BoopPartMeta, BoopPartMeta,
BoopPart BoopPart,
} from './types'; } from "./types";
export type {BoopGame} from './types-extensions'; export type { BoopGame } from "./types-extensions";
// Constants // Constants
export { export { BOARD_SIZE, MAX_PIECES_PER_PLAYER, WIN_LENGTH } from "./constants";
BOARD_SIZE,
MAX_PIECES_PER_PLAYER,
WIN_LENGTH
} from './constants';
// State // State
export { export { createInitialState, type BoopState } from "./state";
createInitialState,
type BoopState
} from './state';
// Prompts // Prompts
export {prompts} from './prompts'; export { prompts } from "./prompts";
// Commands // Commands
export { export {
registry, start,
start, place as placeCmd,
place as placeCmd, boop as boopCmd,
boop as boopCmd, checkWin as checkWinCmd,
checkWin as checkWinCmd, checkGraduates as checkGraduatesCmd,
checkGraduates as checkGraduatesCmd, checkFullBoard as checkFullBoardCmd,
checkFullBoard as checkFullBoardCmd, turn as turnCmd,
turn as turnCmd } from "./commands";
} from './commands';
// Utils // Utils
export { export {
getLineCandidates, getLineCandidates,
isInBounds, isInBounds,
isCellOccupied, isCellOccupied,
getNeighborPositions, getNeighborPositions,
findPartInRegion, findPartInRegion,
findPartAtPosition findPartAtPosition,
} from './utils'; } from "./utils";
+294 -264
View File
@@ -1,379 +1,409 @@
import { import {
OnitamaGame, OnitamaGame,
OnitamaState, OnitamaState,
PlayerType, PlayerType,
prompts, prompts,
initializeCards initializeCards,
} from "./types"; } from "./types";
import {createGameCommandRegistry} from "@/core/game"; import { moveToRegion } from "@/core/region";
import {moveToRegion} from "@/core/region";
export const registry = createGameCommandRegistry<OnitamaState>();
/** /**
* 检查位置是否在棋盘范围内 * 检查位置是否在棋盘范围内
*/ */
function isInBounds(x: number, y: number): boolean { function isInBounds(x: number, y: number): boolean {
return x >= 0 && x < 5 && y >= 0 && y < 5; return x >= 0 && x < 5 && y >= 0 && y < 5;
} }
/** /**
* 检查位置是否有棋子 * 检查位置是否有棋子
*/ */
function getPawnAtPosition(state: OnitamaState, x: number, y: number) { function getPawnAtPosition(state: OnitamaState, x: number, y: number) {
const key = `${x},${y}`; const key = `${x},${y}`;
const pawnId = state.regions.board.partMap[key]; const pawnId = state.regions.board.partMap[key];
return pawnId ? state.pawns[pawnId] : null; return pawnId ? state.pawns[pawnId] : null;
} }
/** /**
* 检查玩家是否拥有某张卡牌 * 检查玩家是否拥有某张卡牌
*/ */
function playerHasCard(state: OnitamaState, player: PlayerType, cardName: string): boolean { function playerHasCard(
const cardList = player === 'red' ? state.redCards : state.blackCards; state: OnitamaState,
return cardList.includes(cardName); player: PlayerType,
cardName: string,
): boolean {
const cardList = player === "red" ? state.redCards : state.blackCards;
return cardList.includes(cardName);
} }
/** /**
* 获取卡牌的移动候选项,根据玩家视角进行旋转 * 获取卡牌的移动候选项,根据玩家视角进行旋转
* 黑方需要将卡牌旋转180度 * 黑方需要将卡牌旋转180度
*/ */
export function getCardMoveCandidates(state: OnitamaState, cardName: string, player: PlayerType) { export function getCardMoveCandidates(
const card = state.cards[cardName]; state: OnitamaState,
const candidates = card.moveCandidates; cardName: string,
player: PlayerType,
) {
const card = state.cards[cardName];
const candidates = card.moveCandidates;
// 黑方需要将卡牌旋转180度 // 黑方需要将卡牌旋转180度
if (player === 'black') { if (player === "black") {
return candidates.map(m => ({ dx: -m.dx, dy: -m.dy })); return candidates.map((m) => ({ dx: -m.dx, dy: -m.dy }));
} }
return candidates; return candidates;
} }
/** /**
* 检查移动是否合法 * 检查移动是否合法
*/ */
export function isValidMove(state: OnitamaState, cardName: string, fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): string | null { export function isValidMove(
// 检查玩家是否拥有该卡牌 state: OnitamaState,
if (!playerHasCard(state, player, cardName)) { cardName: string,
return `玩家 ${player} 不拥有卡牌 ${cardName}`; fromX: number,
} fromY: number,
toX: number,
toY: number,
player: PlayerType,
): string | null {
// 检查玩家是否拥有该卡牌
if (!playerHasCard(state, player, cardName)) {
return `玩家 ${player} 不拥有卡牌 ${cardName}`;
}
// 检查起始位置是否有玩家的棋子 // 检查起始位置是否有玩家的棋子
const fromPawn = getPawnAtPosition(state, fromX, fromY); const fromPawn = getPawnAtPosition(state, fromX, fromY);
if (!fromPawn) { if (!fromPawn) {
return `位置 (${fromX}, ${fromY}) 没有棋子`; return `位置 (${fromX}, ${fromY}) 没有棋子`;
} }
if (fromPawn.owner !== player) { if (fromPawn.owner !== player) {
return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`; return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`;
} }
// 检查卡牌是否存在 // 检查卡牌是否存在
const card = state.cards[cardName]; const card = state.cards[cardName];
if (!card) { if (!card) {
return `卡牌 ${cardName} 不存在`; return `卡牌 ${cardName} 不存在`;
} }
// 计算移动偏移量 // 计算移动偏移量
const dx = toX - fromX; const dx = toX - fromX;
const dy = toY - fromY; const dy = toY - fromY;
// 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度) // 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度)
const candidates = getCardMoveCandidates(state, cardName, player); const candidates = getCardMoveCandidates(state, cardName, player);
const isValid = candidates.some(m => m.dx === dx && m.dy === dy); const isValid = candidates.some((m) => m.dx === dx && m.dy === dy);
if (!isValid) { if (!isValid) {
return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`; return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`;
} }
// 检查目标位置是否在棋盘内 // 检查目标位置是否在棋盘内
if (!isInBounds(toX, toY)) { if (!isInBounds(toX, toY)) {
return `目标位置 (${toX}, ${toY}) 超出棋盘范围`; return `目标位置 (${toX}, ${toY}) 超出棋盘范围`;
} }
// 检查目标位置是否有己方棋子 // 检查目标位置是否有己方棋子
const toPawn = getPawnAtPosition(state, toX, toY); const toPawn = getPawnAtPosition(state, toX, toY);
if (toPawn && toPawn.owner === player) { if (toPawn && toPawn.owner === player) {
return `目标位置 (${toX}, ${toY}) 已有己方棋子`; return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
} }
return null; return null;
} }
/** /**
* 执行移动 * 执行移动
*/ */
async function handleMove(game: OnitamaGame, player: PlayerType, cardName: string, fromX: number, fromY: number, toX: number, toY: number) { async function move(
const state = game.value; game: OnitamaGame,
player: PlayerType,
cardName: string,
fromX: number,
fromY: number,
toX: number,
toY: number,
) {
const state = game.value;
// 验证移动 // 验证移动
const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player); const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player);
if (error) { if (error) {
throw new Error(error); throw new Error(error);
}
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
await game.produceAsync((state) => {
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
// 如果目标位置有敌方棋子,将其移除(吃掉)
if (capturedPawnId) {
const capturedPawn = state.pawns[capturedPawnId];
moveToRegion(capturedPawn, state.regions.board, null);
} }
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null; // 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
await game.produceAsync(state => { // 交换卡牌
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id]; await swapCard(game, player, cardName);
// 如果目标位置有敌方棋子,将其移除(吃掉) return {
if (capturedPawnId) { from: { x: fromX, y: fromY },
const capturedPawn = state.pawns[capturedPawnId]; to: { x: toX, y: toY },
moveToRegion(capturedPawn, state.regions.board, null); card: cardName,
} captured: capturedPawnId,
};
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
// 交换卡牌
await handleSwapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId
};
} }
const move = registry.register({
schema: 'move <player> <card:string> <fromX:number> <fromY:number> <toX:number> <toY:number>',
run: handleMove
});
/** /**
* 交换卡牌:将使用的卡牌与备用卡牌交换 * 交换卡牌:将使用的卡牌与备用卡牌交换
*/ */
async function handleSwapCard(game: OnitamaGame, player: PlayerType, usedCard: string) { async function swapCard(
await game.produceAsync(state => { game: OnitamaGame,
const spareCard = state.spareCard; player: PlayerType,
const usedCardData = state.cards[usedCard]; usedCard: string,
const spareCardData = state.cards[spareCard]; ) {
await game.produceAsync((state) => {
const spareCard = state.spareCard;
const usedCardData = state.cards[usedCard];
const spareCardData = state.cards[spareCard];
// 从玩家手牌中移除使用的卡牌 // 从玩家手牌中移除使用的卡牌
if (player === 'red') { if (player === "red") {
state.redCards = state.redCards.filter(c => c !== usedCard); state.redCards = state.redCards.filter((c) => c !== usedCard);
state.redCards.push(spareCard); state.redCards.push(spareCard);
} else { } else {
state.blackCards = state.blackCards.filter(c => c !== usedCard); state.blackCards = state.blackCards.filter((c) => c !== usedCard);
state.blackCards.push(spareCard); state.blackCards.push(spareCard);
} }
// 更新卡牌区域 // 更新卡牌区域
usedCardData.regionId = 'spare'; usedCardData.regionId = "spare";
spareCardData.regionId = player; spareCardData.regionId = player;
// 更新备用卡牌 // 更新备用卡牌
state.spareCard = usedCard; state.spareCard = usedCard;
}); });
} }
const swapCard = registry.register({
schema: 'swap-card <player> <card:string>',
run: handleSwapCard
});
/** /**
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置 * 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
* 红色师父需要到达 (2, 4) - 黑色师父的初始位置 * 红色师父需要到达 (2, 4) - 黑色师父的初始位置
* 黑色师父需要到达 (2, 0) - 红色师父的初始位置 * 黑色师父需要到达 (2, 0) - 红色师父的初始位置
*/ */
async function handleCheckConquestWin(game: OnitamaGame): Promise<PlayerType | null> { async function checkConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value; const state = game.value;
// 红色师父到达 (2, 4)(黑色师父的初始位置) // 红色师父到达 (2, 4)(黑色师父的初始位置)
const redMaster = state.pawns['red-master']; const redMaster = state.pawns["red-master"];
if (redMaster && redMaster.regionId === 'board' && redMaster.position[0] === 2 && redMaster.position[1] === 4) { if (
return 'red'; redMaster &&
} redMaster.regionId === "board" &&
redMaster.position[0] === 2 &&
redMaster.position[1] === 4
) {
return "red";
}
// 黑色师父到达 (2, 0)(红色师父的初始位置) // 黑色师父到达 (2, 0)(红色师父的初始位置)
const blackMaster = state.pawns['black-master']; const blackMaster = state.pawns["black-master"];
if (blackMaster && blackMaster.regionId === 'board' && blackMaster.position[0] === 2 && blackMaster.position[1] === 0) { if (
return 'black'; blackMaster &&
} blackMaster.regionId === "board" &&
blackMaster.position[0] === 2 &&
blackMaster.position[1] === 0
) {
return "black";
}
return null; return null;
} }
const checkConquestWin = registry.register({
schema: 'check-conquest-win',
run: handleCheckConquestWin
});
/** /**
* 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上) * 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上)
*/ */
async function handleCheckCaptureWin(game: OnitamaGame): Promise<PlayerType | null> { async function checkCaptureWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value; const state = game.value;
// 红色师父不在棋盘上,黑色获胜 // 红色师父不在棋盘上,黑色获胜
const redMaster = state.pawns['red-master']; const redMaster = state.pawns["red-master"];
if (!redMaster || redMaster.regionId !== 'board') { if (!redMaster || redMaster.regionId !== "board") {
return 'black'; return "black";
} }
// 黑色师父不在棋盘上,红色获胜 // 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns['black-master']; const blackMaster = state.pawns["black-master"];
if (!blackMaster || blackMaster.regionId !== 'board') { if (!blackMaster || blackMaster.regionId !== "board") {
return 'red'; return "red";
} }
return null; return null;
} }
const checkCaptureWin = registry.register({
schema: 'check-capture-win',
run: handleCheckCaptureWin
});
/** /**
* 综合胜利检测 * 综合胜利检测
*/ */
async function handleCheckWin(game: OnitamaGame): Promise<PlayerType | null> { async function checkWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await handleCheckConquestWin(game); const conquestWinner = await checkConquestWin(game);
if (conquestWinner) { if (conquestWinner) {
return conquestWinner; return conquestWinner;
} }
const captureWinner = await handleCheckCaptureWin(game); const captureWinner = await checkCaptureWin(game);
if (captureWinner) { if (captureWinner) {
return captureWinner; return captureWinner;
} }
return null; return null;
} }
const checkWin = registry.register({
schema: 'check-win',
run: handleCheckWin
});
/** /**
* 获取玩家可用的移动 * 获取玩家可用的移动
*/ */
export function getAvailableMoves(state: OnitamaState, player: PlayerType): Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> { export function getAvailableMoves(
const moves: Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> = []; state: OnitamaState,
player: PlayerType,
): Array<{
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}> {
const moves: Array<{
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}> = [];
// 获取玩家的所有卡牌 // 获取玩家的所有卡牌
const cardNames = player === 'red' ? state.redCards : state.blackCards; const cardNames = player === "red" ? state.redCards : state.blackCards;
// 获取玩家的所有棋子 // 获取玩家的所有棋子
const playerPawns = Object.values(state.pawns).filter(p => p.owner === player && p.regionId === 'board'); const playerPawns = Object.values(state.pawns).filter(
(p) => p.owner === player && p.regionId === "board",
);
// 对于每张卡牌 // 对于每张卡牌
for (const cardName of cardNames) { for (const cardName of cardNames) {
// 获取旋转后的移动候选项(黑方需要旋转180度) // 获取旋转后的移动候选项(黑方需要旋转180度)
const candidates = getCardMoveCandidates(state, cardName, player); const candidates = getCardMoveCandidates(state, cardName, player);
// 对于每个棋子 // 对于每个棋子
for (const pawn of playerPawns) { for (const pawn of playerPawns) {
const [fromX, fromY] = pawn.position; const [fromX, fromY] = pawn.position;
// 对于卡牌的每个移动 // 对于卡牌的每个移动
for (const move of candidates) { for (const move of candidates) {
const toX = fromX + move.dx; const toX = fromX + move.dx;
const toY = fromY + move.dy; const toY = fromY + move.dy;
// 检查移动是否合法 // 检查移动是否合法
if (isInBounds(toX, toY)) { if (isInBounds(toX, toY)) {
const targetPawn = getPawnAtPosition(state, toX, toY); const targetPawn = getPawnAtPosition(state, toX, toY);
// 目标位置为空或有敌方棋子 // 目标位置为空或有敌方棋子
if (!targetPawn || targetPawn.owner !== player) { if (!targetPawn || targetPawn.owner !== player) {
moves.push({ card: cardName, fromX, fromY, toX, toY }); moves.push({ card: cardName, fromX, fromY, toX, toY });
} }
}
}
} }
}
} }
}
return moves; return moves;
} }
/** /**
* 处理回合 * 处理回合
*/ */
async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) { async function turn(game: OnitamaGame, turnPlayer: PlayerType) {
const state = game.value; const state = game.value;
const availableMoves = getAvailableMoves(state, turnPlayer); const availableMoves = getAvailableMoves(state, turnPlayer);
let moveOutput; let moveOutput;
if (availableMoves.length === 0) { if (availableMoves.length === 0) {
// 没有可用移动,玩家必须交换一张卡牌 // 没有可用移动,玩家必须交换一张卡牌
const cardToSwap = await game.prompt( const cardToSwap = await game.prompt(
prompts.move, prompts.move,
(player, card, _fromX, _fromY, _toX, _toY) => { (player, card, _fromX, _fromY, _toX, _toY) => {
if (player !== turnPlayer) { if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`; throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!playerHasCard(state, player, card)) {
throw `Player ${player} does not have card ${card}.`;
}
return card;
},
turnPlayer
);
await swapCard(game, turnPlayer, cardToSwap);
moveOutput = { swappedCard: cardToSwap, noMoves: true };
} else {
// 有可用移动,提示玩家选择
moveOutput = await game.prompt(
prompts.move,
(player, card, fromX, fromY, toX, toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
if (error) {
throw error;
}
return { player, card, fromX, fromY, toX, toY };
},
turnPlayer
);
await move(game, moveOutput.player, moveOutput.card, moveOutput.fromX, moveOutput.fromY, moveOutput.toX, moveOutput.toY);
}
// 检查胜利
const winner = await checkWin(game);
await game.produceAsync(state => {
state.winner = winner;
if (!winner) {
state.currentPlayer = state.currentPlayer === 'red' ? 'black' : 'red';
state.turn++;
} }
}); if (!playerHasCard(state, player, card)) {
throw `Player ${player} does not have card ${card}.`;
}
return card;
},
turnPlayer,
);
return { winner, move: moveOutput }; await swapCard(game, turnPlayer, cardToSwap);
moveOutput = { swappedCard: cardToSwap, noMoves: true };
} else {
// 有可用移动,提示玩家选择
moveOutput = await game.prompt(
prompts.move,
(player, card, fromX, fromY, toX, toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
if (error) {
throw error;
}
return { player, card, fromX, fromY, toX, toY };
},
turnPlayer,
);
await move(
game,
moveOutput.player,
moveOutput.card,
moveOutput.fromX,
moveOutput.fromY,
moveOutput.toX,
moveOutput.toY,
);
}
// 检查胜利
const winner = await checkWin(game);
await game.produceAsync((state) => {
state.winner = winner;
if (!winner) {
state.currentPlayer = state.currentPlayer === "red" ? "black" : "red";
state.turn++;
}
});
return { winner, move: moveOutput };
} }
const turn = registry.register({
schema: 'turn <player>',
run: handleTurn
});
/** /**
* 开始游戏主循环 * 开始游戏主循环
*/ */
export async function start(game: OnitamaGame) { export async function start(game: OnitamaGame) {
// Initialize cards with RNG at game start // Initialize cards with RNG at game start
initializeCards(game); initializeCards(game);
while (true) { while (true) {
const currentPlayer = game.value.currentPlayer; const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer); const turnOutput = await turn(game, currentPlayer);
if (turnOutput.winner) { if (turnOutput.winner) {
break; break;
}
} }
}
return game.value; return game.value;
} }
+342 -346
View File
@@ -1,421 +1,417 @@
import {IGameContext} from "@/core/game"; import { IGameContext } from "@/core/game";
import {RegicideState} from "@/samples/regicide/state"; import { RegicideState } from "@/samples/regicide/state";
import {createGameCommandRegistry} from "@/core/game"; import { PlayerType, RegicideCard } from "@/samples/regicide/types";
import {PlayerType, RegicideCard} from "@/samples/regicide/types"; import { CARD_VALUES, FACE_CARDS } from "@/samples/regicide/constants";
import {CARD_VALUES, FACE_CARDS} from "@/samples/regicide/constants"; import { isEnemyDefeated } from "@/samples/regicide/utils";
import {isEnemyDefeated} from "@/samples/regicide/utils";
export type RegicideGame = IGameContext<RegicideState>; export type RegicideGame = IGameContext<RegicideState>;
export const registry = createGameCommandRegistry<RegicideState>();
/** /**
* 打出一张牌(对当前敌人造成伤害) * 打出一张牌(对当前敌人造成伤害)
*/ */
const playCmd = registry.register({ async function playCmd(game: RegicideGame, player: string, cardId: string) {
schema: 'play <player:string> <cardId:string>', const state = game.value;
run: async (game: RegicideGame, player: string, cardId: string) => { const card = state.cards[cardId];
const state = game.value;
const card = state.cards[cardId];
if (!card) { if (!card) {
return {success: false, error: `卡牌 ${cardId} 不存在`}; return { success: false, error: `卡牌 ${cardId} 不存在` };
} }
// 检查卡牌是否在玩家手牌中 // 检查卡牌是否在玩家手牌中
const playerKey = player as PlayerType; const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey]; const playerHand = state.playerHands[playerKey];
if (!playerHand || !playerHand.includes(cardId)) { if (!playerHand || !playerHand.includes(cardId)) {
return {success: false, error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`}; return {
} success: false,
error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`,
};
}
// 检查是否有当前敌人 // 检查是否有当前敌人
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
// 计算伤害(基础伤害为卡牌面值) // 计算伤害(基础伤害为卡牌面值)
let damage = card.value; let damage = card.value;
let attackReduction = 0; let attackReduction = 0;
// 梅花双倍伤害 // 梅花双倍伤害
if (card.suit === 'clubs') { if (card.suit === "clubs") {
damage *= 2; damage *= 2;
} }
// 黑桃降低敌人攻击力 // 黑桃降低敌人攻击力
if (card.suit === 'spades') { if (card.suit === "spades") {
attackReduction = card.value; attackReduction = card.value;
} }
const enemyHpBefore = state.currentEnemy.hp; const enemyHpBefore = state.currentEnemy.hp;
await game.produce(state => { await game.produceAsync((state) => {
// 对敌人造成伤害 // 对敌人造成伤害
state.currentEnemy!.hp -= damage; state.currentEnemy!.hp -= damage;
// 记录黑桃的攻击力降低 // 记录黑桃的攻击力降低
if (attackReduction > 0) { if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction); state.currentEnemy!.value = Math.max(
} 0,
state.currentEnemy!.value - attackReduction,
// 从手牌移除卡牌 );
const hand = state.playerHands[playerKey];
const cardIndex = hand.indexOf(cardId);
if (cardIndex !== -1) {
hand.splice(cardIndex, 1);
}
// 将卡牌移到弃牌堆
state.cards[cardId].regionId = 'discardPile';
// 红心能力:将弃牌堆洗回酒馆牌堆
if (card.suit === 'hearts') {
const discardIds = state.regions.discardPile.childIds.filter(id => id !== state.currentEnemy!.id);
if (discardIds.length > 0) {
// 将弃牌堆(除当前敌人外)移回酒馆牌堆
for (const discardId of discardIds) {
state.cards[discardId].regionId = 'tavernDeck';
}
state.regions.tavernDeck.childIds.push(...discardIds);
state.regions.discardPile.childIds = [state.currentEnemy!.id];
}
}
// 方块能力:从酒馆牌堆抓牌
if (card.suit === 'diamonds') {
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
if (tavernDeckCount > 0) {
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
state.cards[drawCardId].regionId = `hand_${player}`;
hand.push(drawCardId);
}
}
});
// 检查敌人是否被击败
const enemyDefeated = isEnemyDefeated(game.value.currentEnemy);
return {
success: true,
result: {
damage,
attackReduction,
enemyHpBefore,
enemyHpAfter: game.value.currentEnemy!.hp,
enemyDefeated,
suitAbility: card.suit
}
};
} }
});
// 从手牌移除卡牌
const hand = state.playerHands[playerKey];
const cardIndex = hand.indexOf(cardId);
if (cardIndex !== -1) {
hand.splice(cardIndex, 1);
}
// 将卡牌移到弃牌堆
state.cards[cardId].regionId = "discardPile";
// 红心能力:将弃牌堆洗回酒馆牌堆
if (card.suit === "hearts") {
const discardIds = state.regions.discardPile.childIds.filter(
(id) => id !== state.currentEnemy!.id,
);
if (discardIds.length > 0) {
// 将弃牌堆(除当前敌人外)移回酒馆牌堆
for (const discardId of discardIds) {
state.cards[discardId].regionId = "tavernDeck";
}
state.regions.tavernDeck.childIds.push(...discardIds);
state.regions.discardPile.childIds = [state.currentEnemy!.id];
}
}
// 方块能力:从酒馆牌堆抓牌
if (card.suit === "diamonds") {
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
if (tavernDeckCount > 0) {
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
state.cards[drawCardId].regionId = `hand_${player}`;
hand.push(drawCardId);
}
}
});
// 检查敌人是否被击败
const enemyDefeated = isEnemyDefeated(game.value.currentEnemy);
return {
success: true,
result: {
damage,
attackReduction,
enemyHpBefore,
enemyHpAfter: game.value.currentEnemy!.hp,
enemyDefeated,
suitAbility: card.suit,
},
};
}
/** /**
* 打出A配合另一张牌 * 打出A配合另一张牌
*/ */
const playWithACmd = registry.register({ async function playWithACmd(
schema: 'play-with-a <player:string> <aceCardId:string> <otherCardId:string>', game: RegicideGame,
run: async (game: RegicideGame, player: string, aceCardId: string, otherCardId: string) => { player: string,
const state = game.value; aceCardId: string,
const aceCard = state.cards[aceCardId]; otherCardId: string,
const otherCard = state.cards[otherCardId]; ) {
const state = game.value;
const aceCard = state.cards[aceCardId];
const otherCard = state.cards[otherCardId];
if (!aceCard || !otherCard) { if (!aceCard || !otherCard) {
return {success: false, error: '卡牌不存在'}; return { success: false, error: "卡牌不存在" };
} }
// 检查是否是A牌 // 检查是否是A牌
if (aceCard.rank !== 'A') { if (aceCard.rank !== "A") {
return {success: false, error: `第一张牌必须是A`}; return { success: false, error: `第一张牌必须是A` };
} }
const playerKey = player as PlayerType; const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey]; const playerHand = state.playerHands[playerKey];
// 检查两张牌都在手牌中 // 检查两张牌都在手牌中
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) { if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
return {success: false, error: '卡牌不在手牌中'}; return { success: false, error: "卡牌不在手牌中" };
} }
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
// 计算两张牌的总伤害 // 计算两张牌的总伤害
let totalDamage = aceCard.value + otherCard.value; let totalDamage = aceCard.value + otherCard.value;
// 如果另一张牌是梅花,双倍伤害 // 如果另一张牌是梅花,双倍伤害
if (otherCard.suit === 'clubs') { if (otherCard.suit === "clubs") {
totalDamage *= 2; totalDamage *= 2;
} }
let attackReduction = 0; let attackReduction = 0;
if (aceCard.suit === 'spades') { if (aceCard.suit === "spades") {
attackReduction += aceCard.value; attackReduction += aceCard.value;
} }
if (otherCard.suit === 'spades') { if (otherCard.suit === "spades") {
attackReduction += otherCard.value; attackReduction += otherCard.value;
} }
await game.produce(state => { await game.produceAsync((state) => {
// 对敌人造成伤害 // 对敌人造成伤害
state.currentEnemy!.hp -= totalDamage; state.currentEnemy!.hp -= totalDamage;
// 记录黑桃的攻击力降低 // 记录黑桃的攻击力降低
if (attackReduction > 0) { if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction); state.currentEnemy!.value = Math.max(
} 0,
state.currentEnemy!.value - attackReduction,
// 从手牌移除两张牌 );
const hand = state.playerHands[playerKey];
const aceIndex = hand.indexOf(aceCardId);
const otherIndex = hand.indexOf(otherCardId);
if (aceIndex !== -1) hand.splice(aceIndex, 1);
if (otherIndex !== -1) hand.splice(otherIndex, 1);
// 将卡牌移到弃牌堆
state.cards[aceCardId].regionId = 'discardPile';
state.cards[otherCardId].regionId = 'discardPile';
});
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
return {
success: true,
result: {
damage: totalDamage,
attackReduction,
enemyHp: state.currentEnemy!.hp,
enemyDefeated
}
};
} }
});
// 从手牌移除两张牌
const hand = state.playerHands[playerKey];
const aceIndex = hand.indexOf(aceCardId);
const otherIndex = hand.indexOf(otherCardId);
if (aceIndex !== -1) hand.splice(aceIndex, 1);
if (otherIndex !== -1) hand.splice(otherIndex, 1);
// 将卡牌移到弃牌堆
state.cards[aceCardId].regionId = "discardPile";
state.cards[otherCardId].regionId = "discardPile";
});
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
return {
success: true,
result: {
damage: totalDamage,
attackReduction,
enemyHp: state.currentEnemy!.hp,
enemyDefeated,
},
};
}
/** /**
* 让过(不出牌) * 让过(不出牌)
*/ */
const passCmd = registry.register({ async function passCmd(game: RegicideGame, player: string) {
schema: 'pass <player:string>', // 即使让过,也会受到敌人反击(在回合结束时处理)
run: async (game: RegicideGame, player: string) => { return { success: true, result: { message: `${player} 让过` } };
// 即使让过,也会受到敌人反击(在回合结束时处理) }
return {success: true, result: {message: `${player} 让过`}};
}
});
/** /**
* 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌 * 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌
*/ */
const enemyCounterattackCmd = registry.register({ async function enemyCounterattackCmd(
schema: 'counterattack <player:string> <discardCards:string[]>', game: RegicideGame,
run: async (game: RegicideGame, player: string, discardCards: string[]) => { player: string,
const state = game.value; discardCards: string[],
) {
const state = game.value;
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
const playerKey = player as PlayerType; const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey]; const playerHand = state.playerHands[playerKey];
// 检查要弃的牌都在手牌中 // 检查要弃的牌都在手牌中
for (const cardId of discardCards) { for (const cardId of discardCards) {
if (!playerHand.includes(cardId)) { if (!playerHand.includes(cardId)) {
return {success: false, error: `卡牌 ${cardId} 不在手牌中`}; return { success: false, error: `卡牌 ${cardId} 不在手牌中` };
}
}
// 计算弃牌的点数和
let totalValue = 0;
for (const cardId of discardCards) {
const card = state.cards[cardId];
if (card) {
totalValue += card.value;
}
}
const enemyAttack = state.currentEnemy.value;
// 检查点数和是否 >= 敌人攻击力
if (totalValue < enemyAttack) {
return {
success: false,
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`
};
}
// 执行弃牌
await game.produce(state => {
const hand = state.playerHands[playerKey];
for (const cardId of discardCards) {
const index = hand.indexOf(cardId);
if (index !== -1) {
hand.splice(index, 1);
}
state.cards[cardId].regionId = 'discardPile';
}
});
return {
success: true,
result: {
discardedCards: discardCards,
totalValue,
enemyAttack
}
};
} }
}); }
// 计算弃牌的点数和
let totalValue = 0;
for (const cardId of discardCards) {
const card = state.cards[cardId];
if (card) {
totalValue += card.value;
}
}
const enemyAttack = state.currentEnemy.value;
// 检查点数和是否 >= 敌人攻击力
if (totalValue < enemyAttack) {
return {
success: false,
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`,
};
}
// 执行弃牌
await game.produceAsync((state) => {
const hand = state.playerHands[playerKey];
for (const cardId of discardCards) {
const index = hand.indexOf(cardId);
if (index !== -1) {
hand.splice(index, 1);
}
state.cards[cardId].regionId = "discardPile";
}
});
return {
success: true,
result: {
discardedCards: discardCards,
totalValue,
enemyAttack,
},
};
}
/** /**
* 检查敌人是否被击败,如果击败则翻开下一个敌人 * 检查敌人是否被击败,如果击败则翻开下一个敌人
*/ */
const checkEnemyDefeatedCmd = registry.register({ async function checkEnemyDefeatedCmd(game: RegicideGame) {
schema: 'check-enemy', const state = game.value;
run: async (game: RegicideGame) => {
const state = game.value;
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false as const, error: "没有活跃的敌人" };
} }
const defeated = state.currentEnemy.hp <= 0; const defeated = state.currentEnemy.hp <= 0;
if (defeated) { if (defeated) {
const defeatedEnemy = {...state.currentEnemy}; const defeatedEnemy = { ...state.currentEnemy };
await game.produce(state => { await game.produceAsync((state) => {
// 将当前敌人移到弃牌堆 // 将当前敌人移到弃牌堆
state.regions.discardPile.childIds.push(state.currentEnemy!.id); state.regions.discardPile.childIds.push(state.currentEnemy!.id);
// 翻开下一个敌人 // 翻开下一个敌人
if (state.enemyDeck.length > 0) { if (state.enemyDeck.length > 0) {
const nextEnemy = state.enemyDeck.shift()!; const nextEnemy = state.enemyDeck.shift()!;
state.currentEnemy = nextEnemy; state.currentEnemy = nextEnemy;
} else { } else {
// 没有更多敌人了 // 没有更多敌人了
state.currentEnemy = null; state.currentEnemy = null;
} }
}); });
// 检查是否胜利(没有更多敌人) // 检查是否胜利(没有更多敌人)
if (!game.value.currentEnemy) { if (!game.value.currentEnemy) {
await game.produce(state => { await game.produceAsync((state) => {
state.phase = 'victory'; state.phase = "victory";
state.winner = true; state.winner = true;
}); });
}
return {
success: true,
result: {
defeated: true,
defeatedEnemy,
nextEnemy: game.value.currentEnemy,
enemiesRemaining: game.value.enemyDeck.length
}
};
}
return {
success: true,
result: {
defeated: false,
currentEnemy: {...state.currentEnemy},
enemiesRemaining: state.enemyDeck.length
}
};
} }
});
return {
success: true as const,
result: {
defeated: true,
defeatedEnemy,
nextEnemy: game.value.currentEnemy,
enemiesRemaining: game.value.enemyDeck.length,
},
};
}
return {
success: true,
result: {
defeated: false,
currentEnemy: { ...state.currentEnemy },
enemiesRemaining: state.enemyDeck.length,
},
};
}
/** /**
* 检查玩家是否有可出的牌 * 检查玩家是否有可出的牌
*/ */
const checkCanPlayCmd = registry.register({ async function checkCanPlayCmd(game: RegicideGame, player: string) {
schema: 'check-can-play <player:string>', const state = game.value;
run: async (game: RegicideGame, player: string) => { const playerKey = player as PlayerType;
const state = game.value; const playerHand = state.playerHands[playerKey];
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
const canPlay = playerHand.length > 0; const canPlay = playerHand.length > 0;
const canPlayWithA = playerHand.some(cardId => { const canPlayWithA = playerHand.some((cardId) => {
const card = state.cards[cardId]; const card = state.cards[cardId];
return card && card.rank === 'A' && playerHand.length > 1; return card && card.rank === "A" && playerHand.length > 1;
}); });
return { return {
success: true, success: true,
result: { result: {
canPlay, canPlay,
canPlayWithA, canPlayWithA,
handSize: playerHand.length handSize: playerHand.length,
} },
}; };
} }
});
/** /**
* 检查酒馆牌堆是否为空 * 检查酒馆牌堆是否为空
*/ */
const checkTavernDeckCmd = registry.register({ async function checkTavernDeckCmd(game: RegicideGame) {
schema: 'check-tavern-deck', const state = game.value;
run: async (game: RegicideGame) => { const isEmpty = state.regions.tavernDeck.childIds.length === 0;
const state = game.value;
const isEmpty = state.regions.tavernDeck.childIds.length === 0;
// 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败 // 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败
if (isEmpty) { if (isEmpty) {
const allHandsEmpty = Object.values(state.playerHands).every(hand => hand.length === 0); const allHandsEmpty = Object.values(state.playerHands).every(
if (allHandsEmpty) { (hand) => hand.length === 0,
await game.produce(state => { );
state.phase = 'defeat'; if (allHandsEmpty) {
state.winner = false; await game.produceAsync((state) => {
}); state.phase = "defeat";
} state.winner = false;
} });
return {
success: true,
result: {
isEmpty,
cardsRemaining: state.regions.tavernDeck.childIds.length
}
};
} }
}); }
return {
success: true,
result: {
isEmpty,
cardsRemaining: state.regions.tavernDeck.childIds.length,
},
};
}
/** /**
* 下一个玩家回合 * 下一个玩家回合
*/ */
const nextTurnCmd = registry.register({ async function nextTurnCmd(game: RegicideGame) {
schema: 'next-turn', const state = game.value;
run: async (game: RegicideGame) => { await game.produce((state) => {
const state = game.value; state.currentPlayerIndex =
await game.produce(state => { (state.currentPlayerIndex + 1) % state.playerCount;
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount; });
});
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4']; const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
const currentPlayer = players[game.value.currentPlayerIndex]; const currentPlayer = players[game.value.currentPlayerIndex];
return { return {
success: true, success: true,
result: { result: {
currentPlayer, currentPlayer,
currentPlayerIndex: game.value.currentPlayerIndex currentPlayerIndex: game.value.currentPlayerIndex,
} },
}; };
} }
});
export { export {
playCmd as play, playCmd as play,
playWithACmd as playWithA, playWithACmd as playWithA,
passCmd as pass, passCmd as pass,
enemyCounterattackCmd as enemyCounterattack, enemyCounterattackCmd as enemyCounterattack,
checkEnemyDefeatedCmd as checkEnemy, checkEnemyDefeatedCmd as checkEnemy,
checkCanPlayCmd as checkCanPlay, checkCanPlayCmd as checkCanPlay,
checkTavernDeckCmd as checkTavernDeck, checkTavernDeckCmd as checkTavernDeck,
nextTurnCmd as nextTurn, nextTurnCmd as nextTurn,
}; };
+219 -185
View File
@@ -1,8 +1,15 @@
import {IGameContext} from "@/core/game"; import { IGameContext } from "@/core/game";
import {RegicideState} from "@/samples/regicide/state"; import { RegicideState } from "@/samples/regicide/state";
import {buildEnemyDeck, buildTavernDeck, createAllCards, getPlayerHandRegionId} from "@/samples/regicide/utils"; import {
import {INITIAL_HAND_SIZE} from "@/samples/regicide/constants"; buildEnemyDeck,
import {Enemy, PlayerType, RegicideCard} from "@/samples/regicide/types"; buildTavernDeck,
createAllCards,
getPlayerHandRegionId,
} from "@/samples/regicide/utils";
import { INITIAL_HAND_SIZE } from "@/samples/regicide/constants";
import { Enemy, PlayerType, RegicideCard } from "@/samples/regicide/types";
import { checkEnemy, nextTurn, pass, playWithA } from "./commands";
import { enemyCounterattackCmd, playCmd } from ".";
export type RegicideGame = IGameContext<RegicideState>; export type RegicideGame = IGameContext<RegicideState>;
@@ -12,228 +19,255 @@ export type RegicideGame = IGameContext<RegicideState>;
* @param playerCount 玩家数量(1-4) * @param playerCount 玩家数量(1-4)
* @param seed 随机种子(可选) * @param seed 随机种子(可选)
*/ */
export async function setupGame(game: RegicideGame, playerCount: number, seed?: number) { export async function setupGame(
if (playerCount < 1 || playerCount > 4) { game: RegicideGame,
throw new Error('玩家数量必须为 1-4 人'); playerCount: number,
seed?: number,
) {
if (playerCount < 1 || playerCount > 4) {
throw new Error("玩家数量必须为 1-4 人");
}
if (seed) {
// RNG seeding handled by game context
}
// 创建所有卡牌
const allCards = createAllCards();
// 构建敌人牌堆(J/Q/K)
const enemyDeck = buildEnemyDeck(game._rng);
// 构建酒馆牌堆(A-10)
const tavernDeck = buildTavernDeck(game._rng);
// 初始化游戏状态
await game.produceAsync((state) => {
state.cards = allCards;
state.playerCount = playerCount;
state.currentPlayerIndex = 0;
state.enemyDeck = enemyDeck;
// 设置酒馆牌堆区域
for (const card of tavernDeck) {
card.regionId = "tavernDeck";
state.regions.tavernDeck.childIds.push(card.id);
} }
if (seed) { // 设置敌人牌堆区域(只存储ID,敌人是独立对象)
// RNG seeding handled by game context state.regions.enemyDeck.childIds = enemyDeck.map((e) => e.id);
// 给每个玩家发牌
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
for (let i = 0; i < playerCount; i++) {
const player = players[i];
const regionId = getPlayerHandRegionId(player);
for (let j = 0; j < INITIAL_HAND_SIZE; j++) {
if (tavernDeck.length === 0) break;
const card = tavernDeck.shift()!;
card.regionId = regionId;
state.playerHands[player].push(card.id);
const region = state.regions[regionId as keyof typeof state.regions];
region.childIds.push(card.id);
}
} }
// 创建所有卡牌 // 翻开第一个敌人
const allCards = createAllCards(); if (enemyDeck.length > 0) {
const firstEnemy = enemyDeck.shift()!;
// 构建敌人牌堆(J/Q/K) state.currentEnemy = firstEnemy;
const enemyDeck = buildEnemyDeck(game._rng); }
});
// 构建酒馆牌堆(A-10)
const tavernDeck = buildTavernDeck(game._rng);
// 初始化游戏状态
await game.produceAsync(state => {
state.cards = allCards;
state.playerCount = playerCount;
state.currentPlayerIndex = 0;
state.enemyDeck = enemyDeck;
// 设置酒馆牌堆区域
for (const card of tavernDeck) {
card.regionId = 'tavernDeck';
state.regions.tavernDeck.childIds.push(card.id);
}
// 设置敌人牌堆区域(只存储ID,敌人是独立对象)
state.regions.enemyDeck.childIds = enemyDeck.map(e => e.id);
// 给每个玩家发牌
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
for (let i = 0; i < playerCount; i++) {
const player = players[i];
const regionId = getPlayerHandRegionId(player);
for (let j = 0; j < INITIAL_HAND_SIZE; j++) {
if (tavernDeck.length === 0) break;
const card = tavernDeck.shift()!;
card.regionId = regionId;
state.playerHands[player].push(card.id);
const region = state.regions[regionId as keyof typeof state.regions];
region.childIds.push(card.id);
}
}
// 翻开第一个敌人
if (enemyDeck.length > 0) {
const firstEnemy = enemyDeck.shift()!;
state.currentEnemy = firstEnemy;
}
});
} }
/** /**
* 启动游戏主循环 * 启动游戏主循环
*/ */
export async function start(game: RegicideGame) { export async function start(game: RegicideGame) {
const state = game.value; const state = game.value;
// 检查游戏是否已设置 // 检查游戏是否已设置
if (!state.currentEnemy) { if (!state.currentEnemy) {
throw new Error('请先调用 setupGame 初始化游戏'); throw new Error("请先调用 setupGame 初始化游戏");
}
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
// 主游戏循环
while (state.phase === "playing") {
const currentPlayerIndex = state.currentPlayerIndex;
const currentPlayer = players[currentPlayerIndex];
// 检查当前玩家是否有手牌
const currentHand = state.playerHands[currentPlayer];
if (currentHand.length === 0) {
// 玩家没有手牌,跳过回合
await game.produceAsync((state) => {
state.currentPlayerIndex =
(state.currentPlayerIndex + 1) % state.playerCount;
});
continue;
} }
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4']; // 等待玩家输入(出牌或让过)
// 这里需要外部通过 prompt 系统获取输入
// 实际使用时由 UI 或测试代码提供输入
// 主游戏循环 // 循环会在外部调用 play/pass 命令后继续
while (state.phase === 'playing') { // 当 phase 变为 'victory' 或 'defeat' 时退出
const currentPlayerIndex = state.currentPlayerIndex; break;
const currentPlayer = players[currentPlayerIndex]; }
// 检查当前玩家是否有手牌 return game.value;
const currentHand = state.playerHands[currentPlayer];
if (currentHand.length === 0) {
// 玩家没有手牌,跳过回合
await game.produceAsync(state => {
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount;
});
continue;
}
// 等待玩家输入(出牌或让过)
// 这里需要外部通过 prompt 系统获取输入
// 实际使用时由 UI 或测试代码提供输入
// 循环会在外部调用 play/pass 命令后继续
// 当 phase 变为 'victory' 或 'defeat' 时退出
break;
}
return game.value;
} }
/** /**
* 处理完整的玩家回合 * 处理完整的玩家回合
*/ */
export async function playTurn(game: RegicideGame, player: PlayerType, action: 'play' | 'pass', cardId?: string, secondCardId?: string) { export async function playTurn(
const state = game.value; game: RegicideGame,
player: PlayerType,
action: "play" | "pass",
cardId?: string,
secondCardId?: string,
) {
const state = game.value;
if (state.phase !== 'playing') { if (state.phase !== "playing") {
return {success: false, error: '游戏已结束'}; return { success: false, error: "游戏已结束" };
} }
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
let playResult: any; let playResult: any;
// 执行玩家动作 // 执行玩家动作
if (action === 'play' && cardId) { if (action === "play" && cardId) {
// 检查是否是A配合另一张牌 // 检查是否是A配合另一张牌
const card = state.cards[cardId]; const card = state.cards[cardId];
if (card.rank === 'A' && secondCardId) { if (card.rank === "A" && secondCardId) {
playResult = await game.run(`play-with-a ${player} ${cardId} ${secondCardId}`); playResult = await playWithA(game, player, cardId, secondCardId);
} else {
playResult = await game.run(`play ${player} ${cardId}`);
}
} else { } else {
// 让过 playResult = await playCmd(game, player, cardId);
playResult = await game.run(`pass ${player}`);
} }
} else {
// 让过
playResult = await pass(game, player);
}
if (!playResult.success) { if (!playResult.success) {
return playResult; return playResult;
} }
// 检查敌人是否被击败 // 检查敌人是否被击败
const checkResult = await game.run<{defeated: boolean; currentEnemy?: any; nextEnemy?: any; defeatedEnemy?: any; enemiesRemaining?: number}>('check-enemy'); const checkResult = await checkEnemy(game);
if (!checkResult.success) { if (!checkResult.success) {
return checkResult; return checkResult;
} }
// 如果敌人未被击败,处理反击
if (!checkResult.result.defeated) {
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: false,
needsDiscard: true,
enemyAttack: state.currentEnemy.value,
playerHand: state.playerHands[player]
}
};
}
// 敌人被击败,检查是否还有更多敌人
if (state.enemyDeck.length === 0 && state.currentEnemy && state.currentEnemy.hp <= 0) {
await game.produceAsync(state => {
state.phase = 'victory';
state.winner = true;
});
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
gameWon: true
}
};
}
// 切换到下一个玩家
await game.run('next-turn');
// 如果敌人未被击败,处理反击
if (!checkResult.result.defeated) {
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
return { return {
success: true, success: true,
result: { result: {
playResult: playResult.result, playResult: playResult.result,
enemyDefeated: true, enemyDefeated: false,
nextEnemy: state.currentEnemy needsDiscard: true,
} enemyAttack: state.currentEnemy.value,
playerHand: state.playerHands[player],
},
}; };
}
// 敌人被击败,检查是否还有更多敌人
if (
state.enemyDeck.length === 0 &&
state.currentEnemy &&
state.currentEnemy.hp <= 0
) {
await game.produceAsync((state) => {
state.phase = "victory";
state.winner = true;
});
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
gameWon: true,
},
};
}
// 切换到下一个玩家
await nextTurn(game);
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
nextEnemy: state.currentEnemy,
},
};
} }
/** /**
* 处理反击阶段的弃牌 * 处理反击阶段的弃牌
*/ */
export async function handleCounterattack(game: RegicideGame, player: PlayerType, discardCardIds: string[]) { export async function handleCounterattack(
const result = await game.run(`counterattack ${player} ${JSON.stringify(discardCardIds)}`); game: RegicideGame,
player: PlayerType,
if (!result.success) { discardCardIds: string[],
// 弃牌失败(点数和不足),游戏失败 ) {
await game.produceAsync(state => { const result = await enemyCounterattackCmd(game, player, discardCardIds);
state.phase = 'defeat'; if (!result.success) {
state.winner = false; // 弃牌失败(点数和不足),游戏失败
}); await game.produceAsync((state) => {
return result; state.phase = "defeat";
} state.winner = false;
});
// 弃牌成功,切换到下一个玩家
await game.run('next-turn');
return result; return result;
}
// 弃牌成功,切换到下一个玩家
await nextTurn(game);
return result;
} }
/** /**
* 获取当前游戏状态摘要 * 获取当前游戏状态摘要
*/ */
export function getGameStatus(game: RegicideGame) { export function getGameStatus(game: RegicideGame) {
const state = game.value; const state = game.value;
return { return {
phase: state.phase, phase: state.phase,
currentPlayer: ['player1', 'player2', 'player3', 'player4'][state.currentPlayerIndex], currentPlayer: ["player1", "player2", "player3", "player4"][
currentEnemy: state.currentEnemy ? { state.currentPlayerIndex
...state.currentEnemy, ],
hpPercent: Math.round((state.currentEnemy.hp / state.currentEnemy.maxHp) * 100) currentEnemy: state.currentEnemy
} : null, ? {
enemiesRemaining: state.enemyDeck.length, ...state.currentEnemy,
tavernDeckCount: state.regions.tavernDeck.childIds.length, hpPercent: Math.round(
discardPileCount: state.regions.discardPile.childIds.length, (state.currentEnemy.hp / state.currentEnemy.maxHp) * 100,
playerHands: Object.fromEntries( ),
Object.entries(state.playerHands).map(([player, hand]) => [player, hand.length]) }
), : null,
winner: state.winner enemiesRemaining: state.enemyDeck.length,
}; tavernDeckCount: state.regions.tavernDeck.childIds.length,
discardPileCount: state.regions.discardPile.childIds.length,
playerHands: Object.fromEntries(
Object.entries(state.playerHands).map(([player, hand]) => [
player,
hand.length,
]),
),
winner: state.winner,
};
} }
+45 -49
View File
@@ -1,67 +1,63 @@
// Types // Types
export type { export type {
SuitType, SuitType,
CardRank, CardRank,
PlayerType, PlayerType,
RegionType, RegionType,
RegicideCardMeta, RegicideCardMeta,
RegicideCard, RegicideCard,
Enemy, Enemy,
GamePhase GamePhase,
} from './types'; } from "./types";
// Constants // Constants
export { export {
ENEMY_COUNT, ENEMY_COUNT,
INITIAL_HAND_SIZE, INITIAL_HAND_SIZE,
CARD_VALUES, CARD_VALUES,
ALL_SUITS, ALL_SUITS,
ALL_RANKS, ALL_RANKS,
FACE_CARDS, FACE_CARDS,
NUMBER_CARDS NUMBER_CARDS,
} from './constants'; } from "./constants";
// State // State
export { export { createInitialState, type RegicideState } from "./state";
createInitialState,
type RegicideState
} from './state';
// Prompts // Prompts
export {prompts} from './prompts'; export { prompts } from "./prompts";
// Commands // Commands
export { export {
registry, play as playCmd,
play as playCmd, playWithA as playWithACmd,
playWithA as playWithACmd, pass as passCmd,
pass as passCmd, enemyCounterattack as enemyCounterattackCmd,
enemyCounterattack as enemyCounterattackCmd, checkEnemy as checkEnemyCmd,
checkEnemy as checkEnemyCmd, checkCanPlay as checkCanPlayCmd,
checkCanPlay as checkCanPlayCmd, checkTavernDeck as checkTavernDeckCmd,
checkTavernDeck as checkTavernDeckCmd, nextTurn as nextTurnCmd,
nextTurn as nextTurnCmd, } from "./commands";
} from './commands';
// Game // Game
export { export {
setupGame, setupGame,
start, start,
playTurn, playTurn,
handleCounterattack, handleCounterattack,
getGameStatus, getGameStatus,
type RegicideGame type RegicideGame,
} from './game'; } from "./game";
// Utils // Utils
export { export {
getCardValue, getCardValue,
createCard, createCard,
createEnemy, createEnemy,
createAllCards, createAllCards,
buildEnemyDeck, buildEnemyDeck,
buildTavernDeck, buildTavernDeck,
drawFromDeck, drawFromDeck,
isEnemyDefeated, isEnemyDefeated,
getPlayerHandRegionId getPlayerHandRegionId,
} from './utils'; } from "./utils";
+27 -31
View File
@@ -1,10 +1,6 @@
import { Part } from "@/core/part"; import { Part } from "@/core/part";
import { createRegion } from "@/core/region"; import { createRegion } from "@/core/region";
import { import { createPromptDef, IGameContext } from "@/core/game";
createGameCommandRegistry,
createPromptDef,
IGameContext,
} from "@/core/game";
const BOARD_SIZE = 3; const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
@@ -69,7 +65,6 @@ export function createInitialState() {
} }
export type TicTacToeState = ReturnType<typeof createInitialState>; export type TicTacToeState = ReturnType<typeof createInitialState>;
export type TicTacToeGame = IGameContext<TicTacToeState>; export type TicTacToeGame = IGameContext<TicTacToeState>;
export const registry = createGameCommandRegistry<TicTacToeState>();
export const prompts = { export const prompts = {
play: createPromptDef<[PlayerType, number, number]>( play: createPromptDef<[PlayerType, number, number]>(
"play <player> <row:number> <col:number>", "play <player> <row:number> <col:number>",
@@ -95,34 +90,35 @@ export async function start(game: TicTacToeGame) {
return game.value; return game.value;
} }
const turn = registry.register({ async function turn(
schema: "turn <player> <turnNumber:number>", game: TicTacToeGame,
async run(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { turnPlayer: PlayerType,
const { player, row, col } = await game.prompt( turnNumber: number,
prompts.play, ) {
(player, row, col) => { const { player, row, col } = await game.prompt(
if (player !== turnPlayer) { prompts.play,
throw `Invalid player: ${player}. Expected ${turnPlayer}.`; (player, row, col) => {
} else if (!isValidMove(row, col)) { if (player !== turnPlayer) {
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`; throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
} else if (isCellOccupied(game, row, col)) { } else if (!isValidMove(row, col)) {
throw `Cell (${row}, ${col}) is already occupied.`; throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
} else { } else if (isCellOccupied(game, row, col)) {
return { player, row, col }; throw `Cell (${row}, ${col}) is already occupied.`;
} } else {
}, return { player, row, col };
game.value.currentPlayer, }
); },
game.value.currentPlayer,
);
placePiece(game, row, col, turnPlayer); placePiece(game, row, col, turnPlayer);
const winner = checkWinner(game); const winner = checkWinner(game);
if (winner) return { winner }; if (winner) return { winner };
if (turnNumber >= MAX_TURNS) return { winner: "draw" as WinnerType }; if (turnNumber >= MAX_TURNS) return { winner: "draw" as WinnerType };
return { winner: null }; return { winner: null };
}, }
});
function isValidMove(row: number, col: number): boolean { function isValidMove(row: number, col: number): boolean {
return ( return (
+12 -2
View File
@@ -4,7 +4,7 @@ import { CommandSchema } from "./types";
export interface PromptDef<TArgs extends any[]> { export interface PromptDef<TArgs extends any[]> {
schema: CommandSchema; schema: CommandSchema;
hint?: string; hintText?: string;
} }
export interface PromptCall<TArgs extends any[] = unknown[], TRes = unknown> { export interface PromptCall<TArgs extends any[] = unknown[], TRes = unknown> {
@@ -26,6 +26,7 @@ export type PromptTryResult =
ok: true; ok: true;
}; };
export type PromptContext = ReturnType<typeof createPromptContext>;
export function createPromptContext() { export function createPromptContext() {
const map = new Map<string, PromptCall>(); const map = new Map<string, PromptCall>();
const handleCall = createMiddlewareChain(async (call: PromptCall) => { const handleCall = createMiddlewareChain(async (call: PromptCall) => {
@@ -44,6 +45,7 @@ export function createPromptContext() {
}); });
function tryCommit<TArgs extends any[]>( function tryCommit<TArgs extends any[]>(
def: PromptDef<TArgs>,
player: string, player: string,
...args: TArgs ...args: TArgs
): PromptTryResult { ): PromptTryResult {
@@ -83,7 +85,14 @@ export function createPromptContext() {
reject: reject!, reject: reject!,
promise, promise,
} as PromptCall<unknown[], unknown>; } as PromptCall<unknown[], unknown>;
return await handleCall.execute(call); return (await handleCall.execute(call)) as TRes;
}
function reset() {
for (const call of map.values()) {
call.reject("Prompt Reset");
}
map.clear();
} }
return { return {
@@ -91,5 +100,6 @@ export function createPromptContext() {
tryCommit, tryCommit,
cancel, cancel,
handleCall, handleCall,
reset,
}; };
} }
+9 -2
View File
@@ -1,7 +1,14 @@
import { Signal, SignalOptions } from "@preact/signals-core"; import { Signal, SignalOptions } from "@preact/signals-core";
import { create } from "mutative"; import { create } from "mutative";
export class MutableSignal<T> extends Signal<T> { export interface MutableSignal<T> extends Signal<T> {
produce(fn: (draft: T) => undefined): void;
addInterruption(promise: Promise<void>): void;
clearInterruptions(): void;
produceAsync(fn: (draft: T) => undefined): Promise<void>;
}
class MutableSignalImpl<T> extends Signal<T> {
private _interruptions: Promise<void>[] = []; private _interruptions: Promise<void>[] = [];
public constructor(t?: T, options?: SignalOptions<T>) { public constructor(t?: T, options?: SignalOptions<T>) {
@@ -41,5 +48,5 @@ export function mutableSignal<T>(
initial?: T, initial?: T,
options?: SignalOptions<T>, options?: SignalOptions<T>,
): MutableSignal<T> { ): MutableSignal<T> {
return new MutableSignal<T>(initial, options); return new MutableSignalImpl<T>(initial, options);
} }