Compare commits
2 Commits
23575dd516
...
d993d55576
| Author | SHA1 | Date |
|---|---|---|
|
|
d993d55576 | |
|
|
eefcef861a |
|
|
@ -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
|
||||||
|
|
@ -1,70 +1,80 @@
|
||||||
type PointerRecord = {
|
import { DisposableBag } from "./disposable";
|
||||||
id: number;
|
|
||||||
x: number;
|
type PointerRecord = {
|
||||||
y: number;
|
id: 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;
|
||||||
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;
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue