refactor: update readme
This commit is contained in:
parent
b71ba12454
commit
768c6ebc84
243
README.md
243
README.md
|
|
@ -7,10 +7,10 @@ Build turn-based board games with reactive state, entity collections, spatial re
|
||||||
## 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 and generic context extension
|
||||||
- **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 System**: CLI-style command parsing with schema validation, type coercion, and prompt support
|
||||||
- **Rule Engine**: Generator-based rule system with reactive context management
|
|
||||||
- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states
|
- **Deterministic RNG**: Seeded pseudo-random number generator (Mulberry32) for reproducible game states
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
@ -21,156 +21,65 @@ npm install boardgame-core
|
||||||
|
|
||||||
## Writing a Game
|
## Writing a Game
|
||||||
|
|
||||||
The core pattern for writing a game is:
|
### Defining a Game
|
||||||
|
|
||||||
1. Define your game state type
|
Each game defines its own context type by extending `IGameContext`, creates a command registry, and exports helper functions:
|
||||||
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
|
```ts
|
||||||
import { RegionEntity, Entity } from 'boardgame-core';
|
import { IGameContext, createGameCommand, createGameContext, createCommandRegistry, registerCommand } from 'boardgame-core';
|
||||||
|
|
||||||
type Player = 'X' | 'O';
|
// 1. Define your game-specific state
|
||||||
|
type MyGameState = {
|
||||||
type GameState = {
|
score: number;
|
||||||
board: RegionEntity;
|
round: number;
|
||||||
parts: Entity<Part & { player: Player }>[];
|
|
||||||
currentPlayer: Player;
|
|
||||||
winner: Player | 'draw' | null;
|
|
||||||
turn: number;
|
|
||||||
};
|
};
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Create the Command Registry
|
// 2. Extend IGameContext with your state
|
||||||
|
type MyGameContext = IGameContext & {
|
||||||
|
state: MyGameState;
|
||||||
|
};
|
||||||
|
|
||||||
`createGameCommandRegistry` ties your state type to the command system. Commands are registered with a schema string and an async handler.
|
// 3. Define typed commands
|
||||||
|
const addScore = createGameCommand<MyGameContext, number>(
|
||||||
```ts
|
'add-score <amount:number>',
|
||||||
import { createGameCommandRegistry } from 'boardgame-core';
|
async function(cmd) {
|
||||||
|
this.context.state.score += cmd.params[0] as number;
|
||||||
const registration = createGameCommandRegistry<GameState>();
|
return this.context.state.score;
|
||||||
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
|
// 4. Create and populate a registry
|
||||||
|
const registry = createCommandRegistry<MyGameContext>();
|
||||||
|
registerCommand(registry, addScore);
|
||||||
|
|
||||||
The `turn` command handles a single player's turn. Use `this.prompt()` to request validated input from the player.
|
// 5. Export a context factory with initial state
|
||||||
|
export function createMyGameContext() {
|
||||||
|
return createGameContext<MyGameContext>(registry, () => ({
|
||||||
|
state: { score: 0, round: 1 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
```ts
|
// 6. Export helper functions for your game logic
|
||||||
registration.add('turn <player>', async function(cmd) {
|
export function getScore(ctx: MyGameContext) {
|
||||||
const [turnPlayer] = cmd.params as [Player];
|
return ctx.state.score;
|
||||||
|
|
||||||
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
|
### Running a Game
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { createGameContextFromModule } from 'boardgame-core';
|
import { createMyGameContext } from './my-game';
|
||||||
import * as yourGame from './your-game';
|
|
||||||
|
|
||||||
const game = createGameContextFromModule(yourGame);
|
const game = createMyGameContext();
|
||||||
game.commands.run('setup');
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use `createGameContext` directly:
|
// Run commands through the context
|
||||||
|
const result = await game.commands.run('add-score 10');
|
||||||
|
if (result.success) {
|
||||||
|
console.log(result.result); // 10
|
||||||
|
}
|
||||||
|
|
||||||
```ts
|
// Access game state
|
||||||
import { createGameContext } from 'boardgame-core';
|
console.log(game.state.score); // 10
|
||||||
import { registry, createInitialState } from './your-game';
|
|
||||||
|
|
||||||
const game = createGameContext(registry, createInitialState);
|
|
||||||
game.commands.run('setup');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Sample Games
|
## Sample Games
|
||||||
|
|
@ -185,7 +94,53 @@ See [`src/samples/tic-tac-toe.ts`](src/samples/tic-tac-toe.ts).
|
||||||
|
|
||||||
A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules.
|
A more complex game with piece types (kittens/cats), supply management, the "boop" push mechanic, and graduation rules.
|
||||||
|
|
||||||
See [`src/samples/boop/index.ts`](src/samples/boop/index.ts) and [Boop rules](src/samples/boop/rules.md).
|
// 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');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
|
@ -193,10 +148,9 @@ See [`src/samples/boop/index.ts`](src/samples/boop/index.ts) and [Boop rules](sr
|
||||||
|
|
||||||
| Export | Description |
|
| Export | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `createGameContext(root?)` | Create a new game context instance |
|
| `IGameContext` | Base interface for the game context (parts, regions, commands, prompts) |
|
||||||
| `createGameCommandRegistry<State>()` | Create a typed command registry for your game state |
|
| `createGameContext<TContext>(registry, initialState?)` | Create a game context instance. `initialState` can be an object or factory function for custom properties |
|
||||||
| `GameContext` | The game context model class |
|
| `createGameCommand<TContext, TResult>(schema, handler)` | Create a typed command with access to `this.context` |
|
||||||
| `invokeRuleContext(pushContext, type, rule)` | Execute a rule with context management |
|
|
||||||
|
|
||||||
### Parts
|
### Parts
|
||||||
|
|
||||||
|
|
@ -227,6 +181,19 @@ See [`src/samples/boop/index.ts`](src/samples/boop/index.ts) and [Boop rules](sr
|
||||||
| `parseCommand(input)` | Parse a command string into a `Command` object |
|
| `parseCommand(input)` | Parse a command string into a `Command` object |
|
||||||
| `parseCommandSchema(schema)` | Parse a schema string into a `CommandSchema` |
|
| `parseCommandSchema(schema)` | Parse a schema string into a `CommandSchema` |
|
||||||
| `validateCommand(cmd, schema)` | Validate a command against a schema |
|
| `validateCommand(cmd, schema)` | Validate a command against a schema |
|
||||||
|
| `parseCommandWithSchema(cmd, schema)` | Parse and validate in one step |
|
||||||
|
| `applyCommandSchema(cmd, schema)` | Apply schema validation and return validated command |
|
||||||
|
| `createCommandRegistry<TContext>()` | Create a new command registry |
|
||||||
|
| `registerCommand(registry, runner)` | Register a command runner |
|
||||||
|
| `unregisterCommand(registry, name)` | Remove a command from the registry |
|
||||||
|
| `hasCommand(registry, name)` | Check if a command exists |
|
||||||
|
| `getCommand(registry, name)` | Get a command runner by name |
|
||||||
|
| `runCommand(registry, context, input)` | Parse and run a command string |
|
||||||
|
| `runCommandParsed(registry, context, command)` | Run a pre-parsed command |
|
||||||
|
| `createCommandRunnerContext(registry, context)` | Create a command runner context |
|
||||||
|
| `CommandRunner` | Command runner type with schema and run function |
|
||||||
|
| `CommandRunnerContext` | Context available inside command handlers |
|
||||||
|
| `PromptEvent` | Event dispatched when a command prompts for input |
|
||||||
|
|
||||||
### Utilities
|
### Utilities
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue