feat: add new functions

This commit is contained in:
hyper 2026-04-02 21:58:11 +08:00
parent 11d6cbd030
commit b71ba12454
4 changed files with 315 additions and 102 deletions

264
README.md
View File

@ -2,10 +2,11 @@
A state management library for board games using [Preact Signals](https://preactjs.com/guide/v10/signals/). A state management library for board games using [Preact Signals](https://preactjs.com/guide/v10/signals/).
Build turn-based board games with reactive state, entity collections, spatial regions, and a command-driven game loop.
## Features ## Features
- **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/) - **Reactive State Management**: Fine-grained reactivity powered by [@preact/signals-core](https://preactjs.com/guide/v10/signals/)
- **Type Safe**: Full TypeScript support with strict mode
- **Entity Collections**: Signal-backed collections for managing game pieces (cards, dice, tokens, meeples, etc.) - **Entity Collections**: Signal-backed collections for managing game pieces (cards, dice, tokens, meeples, etc.)
- **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling - **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling
- **Command Parsing**: CLI-style command parsing with schema validation and type coercion - **Command Parsing**: CLI-style command parsing with schema validation and type coercion
@ -18,107 +19,173 @@ A state management library for board games using [Preact Signals](https://preact
npm install boardgame-core npm install boardgame-core
``` ```
## Usage ## Writing a Game
### Game Context The core pattern for writing a game is:
1. Define your game state type
2. Create a command registry with `createGameCommandRegistry<State>()`
3. Register commands for `setup`, `turn`, and any actions
4. Run `setup` to start the game loop
### Step 1: Define Your State
Every game needs a state type. Use `RegionEntity` for boards/zones and plain fields for everything else.
```ts
import { RegionEntity, Entity } from 'boardgame-core';
type Player = 'X' | 'O';
type GameState = {
board: RegionEntity;
parts: Entity<Part & { player: Player }>[];
currentPlayer: Player;
winner: Player | 'draw' | null;
turn: number;
};
```
### Step 2: Create the Command Registry
`createGameCommandRegistry` ties your state type to the command system. Commands are registered with a schema string and an async handler.
```ts
import { createGameCommandRegistry } from 'boardgame-core';
const registration = createGameCommandRegistry<GameState>();
export const registry = registration.registry;
```
### Step 3: Register the `setup` Command
The `setup` command is the main game loop. It runs turns until a winner is determined.
```ts
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 === 'X' ? 'O' : 'X';
state.turn++;
}
});
if (context.value.winner) break;
}
return context.value;
});
```
### Step 4: Register the `turn` Command
The `turn` command handles a single player's turn. Use `this.prompt()` to request validated input from the player.
```ts
registration.add('turn <player>', async function(cmd) {
const [turnPlayer] = cmd.params as [Player];
const playCmd = await this.prompt(
'play <player> <row:number> <col:number>',
(command) => {
const [player, row, col] = command.params as [Player, number, number];
if (player !== turnPlayer) return `Wrong player.`;
if (isCellOccupied(this.context, row, col)) return `Cell occupied.`;
return null; // null = valid
}
);
const [, row, col] = playCmd.params as [Player, number, number];
placePiece(this.context, row, col, turnPlayer);
const winner = checkWinner(this.context);
return { winner };
});
```
### Managing Part Movement
Move parts between regions with `moveToRegion`, `moveToRegionAll`, and `removeFromRegion`.
```ts
import { moveToRegion, moveToRegionAll, removeFromRegion } from 'boardgame-core';
// Move a single piece to a new region with a new position
moveToRegion(card, handRegion, [0]);
// Move a single piece, keeping its current position
moveToRegion(card, handRegion);
// Move multiple pieces at once with new positions
moveToRegionAll([card1, card2, card3], discardPile, [[0], [1], [2]]);
// Remove a piece from its region (without adding to another)
removeFromRegion(card);
```
### Step 5: Manage Parts on the Board
Parts are game pieces placed inside regions. Use `entity()` to create reactive entities and `produce()` to mutate state.
```ts
import { entity } from 'boardgame-core';
function placePiece(host: Entity<GameState>, row: number, col: number, player: Player) {
const board = host.value.board;
const piece = {
id: `piece-${player}-${host.value.parts.length + 1}`,
region: board,
position: [row, col],
player,
};
host.produce(state => {
const e = entity(piece.id, piece);
state.parts.push(e);
board.produce(draft => {
draft.children.push(e);
});
});
}
```
### Step 6: Run the Game
```ts
import { createGameContextFromModule } from 'boardgame-core';
import * as yourGame from './your-game';
const game = createGameContextFromModule(yourGame);
game.commands.run('setup');
```
Or use `createGameContext` directly:
```ts ```ts
import { createGameContext } from 'boardgame-core'; import { createGameContext } from 'boardgame-core';
import { registry, createInitialState } from './your-game';
const game = createGameContext({ type: 'game' }); const game = createGameContext(registry, createInitialState);
game.commands.run('setup');
// Access entity collections
game.parts.add({ id: 'card1', sides: 2, side: 0, region: /* ... */, position: [0] });
game.regions.add({ id: 'hand', axes: [{ name: 'slot', min: 0, max: 7, align: 'start' }], children: [] });
// Context stack for nested rule scopes
game.pushContext({ type: 'combat' });
const combatCtx = game.latestContext('combat');
game.popContext();
``` ```
### Parts (Cards, Dice, Tokens) ## Sample Games
```ts ### Tic-Tac-Toe
import { flip, flipTo, roll, createRNG } from 'boardgame-core';
const rng = createRNG(42); The simplest example. Shows the basic command loop, 2D board regions, and win detection.
flip(card); // cycle to next side See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts).
flipTo(card, 0); // set to specific side
roll(dice, rng); // random side using seeded RNG
```
### Regions & Alignment ### Boop
```ts A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules.
import { applyAlign, shuffle } from 'boardgame-core';
// Compact cards in a hand towards the start See [`src/samples/boop/index.ts`](src/samples/boop/index.ts) and [Boop rules](src/samples/boop/rules.md).
applyAlign(handRegion);
// Shuffle positions of all parts in a region
shuffle(handRegion, rng);
```
### Command Parsing
```ts
import { parseCommand, parseCommandSchema, validateCommand } from 'boardgame-core';
// Parse a command string
const cmd = parseCommand('move card1 hand --force -x 10');
// { name: 'move', params: ['card1', 'hand'], flags: { force: true }, options: { x: '10' } }
// Define and validate against a schema
const schema = parseCommandSchema('move <from> <to> [--force] [-x: number]');
const result = validateCommand(cmd, schema);
// { valid: true }
```
### Entity Collections
```ts
import { createEntityCollection } from 'boardgame-core';
const collection = createEntityCollection();
collection.add({ id: 'a', name: 'Item A' }, { id: 'b', name: 'Item B' });
const accessor = collection.get('a');
console.log(accessor.value); // reactive access
collection.remove('a');
```
### Rule Engine
```ts
import { createRule, invokeRuleContext } from 'boardgame-core';
const myRule = createRule('drawCard', (ctx) => {
// yield action types to pause and wait for external handling
const action = yield 'draw';
ctx.resolution = action;
});
const result = invokeRuleContext(
game.pushContext.bind(game),
'drawCard',
myRule({ type: 'drawCard', actions: [], handledActions: 0, invocations: [] })
);
```
### Random Number Generation
```ts
import { createRNG } from 'boardgame-core';
const rng = createRNG(12345);
rng.nextInt(6); // 0-5
rng.next(); // [0, 1)
rng.next(100); // [0, 100)
rng.setSeed(999); // reseed
```
## API Reference ## API Reference
@ -127,14 +194,16 @@ rng.setSeed(999); // reseed
| Export | Description | | Export | Description |
|---|---| |---|---|
| `createGameContext(root?)` | Create a new game context instance | | `createGameContext(root?)` | Create a new game context instance |
| `createGameCommandRegistry<State>()` | Create a typed command registry for your game state |
| `GameContext` | The game context model class | | `GameContext` | The game context model class |
| `Context` | Base context type | | `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management |
### Parts ### Parts
| Export | Description | | Export | Description |
|---|---| |---|---|
| `Part` | Entity type representing a game piece (card, die, token, etc.) | | `Part` | Entity type representing a game piece |
| `entity(id, data)` | Create a reactive entity |
| `flip(part)` | Cycle to the next side | | `flip(part)` | Cycle to the next side |
| `flipTo(part, side)` | Set to a specific side | | `flipTo(part, side)` | Set to a specific side |
| `roll(part, rng)` | Randomize side using RNG | | `roll(part, rng)` | Randomize side using RNG |
@ -143,18 +212,13 @@ rng.setSeed(999); // reseed
| Export | Description | | Export | Description |
|---|---| |---|---|
| `Region` | Entity type for spatial grouping of parts | | `RegionEntity` | Entity type for spatial grouping of parts |
| `RegionAxis` | Axis definition with min/max/align | | `RegionAxis` | Axis definition with min/max/align |
| `applyAlign(region)` | Compact parts according to axis alignment | | `applyAlign(region)` | Compact parts according to axis alignment |
| `shuffle(region, rng)` | Randomize part positions | | `shuffle(region, rng)` | Randomize part positions |
| `moveToRegion(part, targetRegion, position?)` | Move a part to another region |
### Rules | `moveToRegionAll(parts, targetRegion, positions?)` | Move multiple parts to another region |
| `removeFromRegion(part)` | Remove a part from its region |
| Export | Description |
|---|---|
| `RuleContext<T>` | Rule execution context type |
| `createRule(type, fn)` | Create a rule generator factory |
| `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management |
### Commands ### Commands

View File

@ -113,4 +113,48 @@ function shuffleCore(region: Region, rng: RNG){
draft.position = posI; draft.position = posI;
}); });
} }
}
export function moveToRegion(part: Entity<Part>, targetRegion: Entity<Region>, position?: number[]) {
const sourceRegion = part.value.region;
batch(() => {
sourceRegion.produce(draft => {
draft.children = draft.children.filter(c => c.id !== part.id);
});
targetRegion.produce(draft => {
draft.children.push(part);
});
part.produce(draft => {
draft.region = targetRegion;
if (position) draft.position = position;
});
});
}
export function moveToRegionAll(parts: Entity<Part>[], targetRegion: Entity<Region>, positions?: number[][]) {
batch(() => {
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const sourceRegion = part.value.region;
sourceRegion.produce(draft => {
draft.children = draft.children.filter(c => c.id !== part.id);
});
targetRegion.produce(draft => {
draft.children.push(part);
});
part.produce(draft => {
draft.region = targetRegion;
if (positions && positions[i]) draft.position = positions[i];
});
}
});
}
export function removeFromRegion(part: Entity<Part>) {
const region = part.value.region;
batch(() => {
region.produce(draft => {
draft.children = draft.children.filter(c => c.id !== part.id);
});
});
} }

View File

@ -11,7 +11,7 @@ export type { Part } from './core/part';
export { flip, flipTo, roll } from './core/part'; export { flip, flipTo, roll } from './core/part';
export type { Region, RegionAxis } from './core/region'; export type { Region, RegionAxis } from './core/region';
export { applyAlign, shuffle, RegionEntity } from './core/region'; export { applyAlign, shuffle, RegionEntity, moveToRegion, moveToRegionAll, removeFromRegion } from './core/region';
// Utils // Utils
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command'; export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';

View File

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { applyAlign, shuffle, type Region, type RegionAxis } from '@/core/region'; import { applyAlign, shuffle, moveToRegion, moveToRegionAll, removeFromRegion, type Region, type RegionAxis } from '@/core/region';
import { createRNG } from '@/utils/rng'; import { createRNG } from '@/utils/rng';
import { entity, Entity } from '@/utils/entity'; import { entity, Entity } from '@/utils/entity';
import { type Part } from '@/core/part'; import { type Part } from '@/core/part';
@ -273,4 +273,109 @@ describe('Region', () => {
expect(results.size).toBeGreaterThan(5); expect(results.size).toBeGreaterThan(5);
}); });
}); });
describe('moveToRegion', () => {
it('should move a part from one region to another', () => {
const sourceAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }];
const targetAxes: RegionAxis[] = [{ name: 'x', min: 0, max: 5 }];
const sourceRegion = createTestRegion(sourceAxes, []);
const targetRegion = createTestRegion(targetAxes, []);
const part: Part = { id: 'p1', region: sourceRegion, position: [2] };
const partEntity = entity(part.id, part);
sourceRegion.value.children.push(partEntity);
expect(sourceRegion.value.children).toHaveLength(1);
expect(targetRegion.value.children).toHaveLength(0);
expect(partEntity.value.region.value.id).toBe('region1');
moveToRegion(partEntity, targetRegion, [0]);
expect(sourceRegion.value.children).toHaveLength(0);
expect(targetRegion.value.children).toHaveLength(1);
expect(partEntity.value.region.value.id).toBe('region1');
expect(partEntity.value.position).toEqual([0]);
});
it('should keep existing position if no position provided', () => {
const sourceRegion = createTestRegion([{ name: 'x' }], []);
const targetRegion = createTestRegion([{ name: 'x' }], []);
const part: Part = { id: 'p1', region: sourceRegion, position: [3] };
const partEntity = entity(part.id, part);
sourceRegion.value.children.push(partEntity);
moveToRegion(partEntity, targetRegion);
expect(partEntity.value.position).toEqual([3]);
});
});
describe('moveToRegionAll', () => {
it('should move multiple parts to a target region', () => {
const sourceRegion = createTestRegion([{ name: 'x' }], []);
const targetRegion = createTestRegion([{ name: 'x' }], []);
const parts = [
entity('p1', { id: 'p1', region: sourceRegion, position: [0] } as Part),
entity('p2', { id: 'p2', region: sourceRegion, position: [1] } as Part),
entity('p3', { id: 'p3', region: sourceRegion, position: [2] } as Part),
];
sourceRegion.value.children.push(...parts);
moveToRegionAll(parts, targetRegion, [[0], [1], [2]]);
expect(sourceRegion.value.children).toHaveLength(0);
expect(targetRegion.value.children).toHaveLength(3);
expect(parts[0].value.position).toEqual([0]);
expect(parts[1].value.position).toEqual([1]);
expect(parts[2].value.position).toEqual([2]);
});
it('should keep existing positions if no positions provided', () => {
const sourceRegion = createTestRegion([{ name: 'x' }], []);
const targetRegion = createTestRegion([{ name: 'x' }], []);
const parts = [
entity('p1', { id: 'p1', region: sourceRegion, position: [5] } as Part),
entity('p2', { id: 'p2', region: sourceRegion, position: [8] } as Part),
];
sourceRegion.value.children.push(...parts);
moveToRegionAll(parts, targetRegion);
expect(parts[0].value.position).toEqual([5]);
expect(parts[1].value.position).toEqual([8]);
});
});
describe('removeFromRegion', () => {
it('should remove a part from its region', () => {
const region = createTestRegion([{ name: 'x' }], []);
const part: Part = { id: 'p1', region: region, position: [2] };
const partEntity = entity(part.id, part);
region.value.children.push(partEntity);
expect(region.value.children).toHaveLength(1);
removeFromRegion(partEntity);
expect(region.value.children).toHaveLength(0);
});
it('should leave other parts unaffected', () => {
const region = createTestRegion([{ name: 'x' }], []);
const p1 = entity('p1', { id: 'p1', region: region, position: [0] } as Part);
const p2 = entity('p2', { id: 'p2', region: region, position: [1] } as Part);
const p3 = entity('p3', { id: 'p3', region: region, position: [2] } as Part);
region.value.children.push(p1, p2, p3);
removeFromRegion(p2);
expect(region.value.children).toHaveLength(2);
expect(region.value.children.map(c => c.value.id)).toEqual(['p1', 'p3']);
});
});
}); });