feat: add new functions
This commit is contained in:
parent
11d6cbd030
commit
b71ba12454
264
README.md
264
README.md
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
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
|
||||
|
||||
- **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.)
|
||||
- **Region System**: Spatial management with multi-axis positioning, alignment, and shuffling
|
||||
- **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
|
||||
```
|
||||
|
||||
## 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
|
||||
import { createGameContext } from 'boardgame-core';
|
||||
import { registry, createInitialState } from './your-game';
|
||||
|
||||
const game = createGameContext({ type: 'game' });
|
||||
|
||||
// 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();
|
||||
const game = createGameContext(registry, createInitialState);
|
||||
game.commands.run('setup');
|
||||
```
|
||||
|
||||
### Parts (Cards, Dice, Tokens)
|
||||
## Sample Games
|
||||
|
||||
```ts
|
||||
import { flip, flipTo, roll, createRNG } from 'boardgame-core';
|
||||
### Tic-Tac-Toe
|
||||
|
||||
const rng = createRNG(42);
|
||||
The simplest example. Shows the basic command loop, 2D board regions, and win detection.
|
||||
|
||||
flip(card); // cycle to next side
|
||||
flipTo(card, 0); // set to specific side
|
||||
roll(dice, rng); // random side using seeded RNG
|
||||
```
|
||||
See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts).
|
||||
|
||||
### Regions & Alignment
|
||||
### Boop
|
||||
|
||||
```ts
|
||||
import { applyAlign, shuffle } from 'boardgame-core';
|
||||
A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules.
|
||||
|
||||
// Compact cards in a hand towards the start
|
||||
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
|
||||
```
|
||||
See [`src/samples/boop/index.ts`](src/samples/boop/index.ts) and [Boop rules](src/samples/boop/rules.md).
|
||||
|
||||
## API Reference
|
||||
|
||||
|
|
@ -127,14 +194,16 @@ rng.setSeed(999); // reseed
|
|||
| Export | Description |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `Context` | Base context type |
|
||||
| `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management |
|
||||
|
||||
### Parts
|
||||
|
||||
| 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 |
|
||||
| `flipTo(part, side)` | Set to a specific side |
|
||||
| `roll(part, rng)` | Randomize side using RNG |
|
||||
|
|
@ -143,18 +212,13 @@ rng.setSeed(999); // reseed
|
|||
|
||||
| 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 |
|
||||
| `applyAlign(region)` | Compact parts according to axis alignment |
|
||||
| `shuffle(region, rng)` | Randomize part positions |
|
||||
|
||||
### Rules
|
||||
|
||||
| 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 |
|
||||
| `moveToRegion(part, targetRegion, position?)` | Move a part to another region |
|
||||
| `moveToRegionAll(parts, targetRegion, positions?)` | Move multiple parts to another region |
|
||||
| `removeFromRegion(part)` | Remove a part from its region |
|
||||
|
||||
### Commands
|
||||
|
||||
|
|
|
|||
|
|
@ -114,3 +114,47 @@ function shuffleCore(region: Region, rng: RNG){
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -11,7 +11,7 @@ export type { Part } from './core/part';
|
|||
export { flip, flipTo, roll } from './core/part';
|
||||
|
||||
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
|
||||
export type { Command, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { entity, Entity } from '@/utils/entity';
|
||||
import { type Part } from '@/core/part';
|
||||
|
|
@ -273,4 +273,109 @@ describe('Region', () => {
|
|||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue