Compare commits

..

2 Commits

Author SHA1 Message Date
hypercross d993d55576 Refactor dnd utility and add drag lifecycle events
Attach pointerup and pointermove listeners to scene.input
for reliable tracking. Emit dragstart, dragmove, and
dragend events alongside drag. Rename relativeX/Y to
deltaX/Y in DragDropEvent and integrate DisposableBag.
2026-04-18 23:33:13 +08:00
hypercross eefcef861a Add game architecture patterns documentation
Document layered architecture, spawner patterns, reactive
UI state, and tween interruption for framework users.
Includes best practices and common pitfalls to avoid.
docs: add architecture patterns documentation

Provide a reference guide for implementing board games with the
framework. Covers layered architecture, reactive scenes, spawner
patterns, UI state management, and common development pitfalls.
2026-04-18 23:32:02 +08:00
2 changed files with 178 additions and 63 deletions

105
docs/OnitamaGamePatterns.md Normal file
View File

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