Compare commits

..

No commits in common. "d993d5557640730e43e624b9c6fcf050b4dbb13b" and "23575dd5165c25936b2bda50f1c3f00d796d893c" have entirely different histories.

2 changed files with 63 additions and 178 deletions

View File

@ -1,105 +0,0 @@
# Game Architecture Patterns & Practices
> Reference guide for implementing board games using `boardgame-phaser` framework.
> Explore `packages/onitama-game/` and `packages/sample-game/` for concrete implementations.
## Architecture Overview
Games follow a layered architecture separating logic, presentation, and UI state:
```
packages/my-game/
├── src/
│ ├── game/ # Pure game logic (re-exported from boardgame-core)
│ ├── scenes/ # Phaser scenes (MenuScene, GameScene)
│ ├── spawners/ # Data-driven object lifecycle
│ ├── renderers/ # Renderers for game objects
│ ├── state/ # UI-only reactive state
│ ├── config.ts # Centralized layout & style constants
│ └── ui/App.tsx # Preact root component
```
| Layer | Responsibility |
|-------|----------------|
| `game/` | State, commands, prompts, validation (pure logic) |
| `scenes/` | Phaser lifecycle, input handling, visual composition |
| `spawners/` | Reactive game object spawn/update/despawn |
| `renderers/` | Phaser visual representation of game objects |
| `state/` | UI-only reactive state (selection, hover, etc.) |
| `ui/` | Preact components bridging Phaser and DOM |
## Core Patterns
### 1. ReactiveScene / GameHostScene
Extend `ReactiveScene`(`packages\framework\src\scenes\ReactiveScene.ts`) to use reactive integration features.
- Access game context for scene navigation
- Use `this.disposables` for auto-cleanup on shutdown.
### 2. Spawner Pattern
Implement `Spawner<TData, TObj>` for data-driven objects.
- `*getData()`: Yield objects that should exist.
- `getKey()`: Unique identifier for diffing.
- `onSpawn()`: Create Phaser objects.
- `onUpdate()`: Handle data changes (animate if needed).
- `onDespawn()`: Clean up with optional animation.
- See: `packages/onitama-game/src/spawners/`
### 3. Reactive UI State
Use `MutableSignal` for UI-only state, separate from game state.
- Mutate via `.produce()`.
- React via `effect()` from `@preact/signals-core`.
- Clean up effects on object `'destroy'` to prevent leaks.
- See: `packages/onitama-game/src/state/ui.ts`
### 4. Custom Containers
Extend `Phaser.GameObjects.Container` to encapsulate visuals and state.
- Store logical state as private signals.
- Use `effect()` for reactive highlights/selections.
### 5. Tween Interruption
Always register state-related tweens: `this.scene.addTweenInterruption(tween)`.
Prevents visual glitches when game state changes mid-animation.
### 6. Scene Navigation
Use `await this.sceneController.launch('SceneKey')`.
Register scenes in `App.tsx` via `<PhaserScene>`. Pass data via `data` prop.
## Best Practices
- **Centralize Config**: Keep `CELL_SIZE`, `BOARD_OFFSET`, colors, etc., in `src/config.ts` with `as const`. Avoid magic numbers.
- **Type Imports**: Use `import type { Foo } from 'bar'` for type-only imports.
- **Input Handling**: Use `this.add.zone()` for grid/cell-based input zones.
- **Cleanup**: Always dispose `effect()` on `'destroy'`. Use `this.disposables.add()` for scene-level resources.
## Common Pitfalls
| Pitfall | Solution |
|---------|----------|
| Duplicate constants across files | Export from single `config.ts` or shared spawner |
| Missing tween interruptions | Always call `addTweenInterruption()` |
| Effect memory leaks | `this.on('destroy', () => dispose())` |
| Hardcoded magic numbers | Extract to `src/config.ts` |
| UI state staleness | Clear related selections on state change |
## Testing
Add Vitest for UI state transitions, coordinate conversions, and spawner logic.
See `packages/framework/` for test setup examples.
## Quick Reference
| Pattern | Purpose |
|---------|---------|
| `GameHostScene` | Connect Phaser to game host |
| `Spawner<T, TObj>` | Reactive object lifecycle |
| `MutableSignal` | UI-only reactive state |
| `effect()` | React to signal changes in Phaser objects |
| `addTweenInterruption()` | Prevent animation race conditions |
| `sceneController.launch()` | Navigate between scenes |
| `spawnEffect()` | Register spawners |
| `this.disposables.add()` | Auto-cleanup resources |
## Related Documents
- `AGENTS.md` — Project overview, commands, and code style
- `docs/GameModule.md` — GameModule implementation guide
- `packages/framework/src/``boardgame-phaser` source code
- `packages/onitama-game/src/` — Complete game implementation reference

View File

@ -1,80 +1,70 @@
import { DisposableBag } from "./disposable"; type PointerRecord = {
id: number;
type PointerRecord = { x: number;
id: number; y: number;
x: number; }
y: number;
};
export enum DragDropEventType { export enum DragDropEventType {
DOWN, DOWN,
UP, UP,
MOVE, MOVE,
} }
export type DragDropEvent = { export type DragDropEvent = {
type: DragDropEventType; type: DragDropEventType,
deltaX: number; relativeX: number;
deltaY: number; relativeY: number;
}; }
export function dragDropEventEffect( export function dragDropEventEffect(
gameObject: Phaser.GameObjects.GameObject, gameObject: Phaser.GameObjects.GameObject,
disposables?: DisposableBag, ) {
): () => void { let down: PointerRecord | null;
let isDragging = false; let up: PointerRecord | null;
let down: PointerRecord | null = null;
function onPointerDown(pointer: Phaser.Input.Pointer) { function onPointerDown(pointer: Phaser.Input.Pointer) {
if (isDragging) return; down = {
isDragging = true; id: pointer.id,
down = { id: pointer.id, x: pointer.x, y: pointer.y }; x: pointer.x,
y: pointer.y
}
up = null;
const event: DragDropEvent = { const type = DragDropEventType.DOWN;
type: DragDropEventType.DOWN, const relativeX = pointer.x - down.x;
deltaX: 0, const relativeY = pointer.y - down.y;
deltaY: 0, gameObject.emit('drag', {type, relativeX, relativeY});
}; }
gameObject.emit("drag", event);
gameObject.emit("dragstart", event);
}
function onPointerUp(pointer: Phaser.Input.Pointer) { function onPointerUp(pointer: Phaser.Input.Pointer) {
if (!isDragging || !down || pointer.id !== down.id) return; if(!down) return;
up = {
id: pointer.id,
x: pointer.x,
y: pointer.y
}
isDragging = false; const type = DragDropEventType.UP;
const deltaX = pointer.x - down.x; const relativeX = pointer.x - down.x;
const deltaY = pointer.y - down.y; const relativeY = pointer.y - down.y;
const event: DragDropEvent = { type: DragDropEventType.UP, deltaX, deltaY }; gameObject.emit('drag', {type, relativeX, relativeY});
gameObject.emit("drag", event); }
gameObject.emit("dragend", event);
down = null;
}
function onPointerMove(pointer: Phaser.Input.Pointer) { function onPointerMove(pointer: Phaser.Input.Pointer) {
if (!isDragging || !down || pointer.id !== down.id) return; if(!down || up) return;
if(down.id !== pointer.id) return;
const deltaX = pointer.x - down.x; const type = DragDropEventType.MOVE;
const deltaY = pointer.y - down.y; const relativeX = pointer.x - down.x;
const event: DragDropEvent = { const relativeY = pointer.y - down.y;
type: DragDropEventType.MOVE, gameObject.emit('drag', {type, relativeX, relativeY});
deltaX, }
deltaY,
};
gameObject.emit("drag", event);
gameObject.emit("dragmove", event);
}
gameObject.on("pointerdown", onPointerDown); gameObject.on('pointerdown', onPointerDown);
gameObject.scene.input.on("pointerup", onPointerUp); gameObject.on('pointerup', onPointerUp);
gameObject.scene.input.on("pointermove", onPointerMove); gameObject.scene.input.on('pointermove', onPointerMove);
return function () {
const dispose = () => { gameObject.off('pointerdown', onPointerDown);
gameObject.off("pointerdown", onPointerDown); gameObject.off('pointerup', onPointerUp);
gameObject.scene.input.off("pointerup", onPointerUp); gameObject.scene.input.off('pointermove', onPointerMove);
gameObject.scene.input.off("pointermove", onPointerMove); }
};
disposables?.add(dispose);
return dispose;
} }