136 lines
5.2 KiB
Markdown
136 lines
5.2 KiB
Markdown
# Intent-Based Architecture
|
|
|
|
Systems communicate through **intents** — one-frame typed data in shared queues — not through direct function calls. This decouples producers from consumers: the system that wants something to happen does not know or care which system processes it.
|
|
|
|
## The Pattern
|
|
|
|
```
|
|
Producer World (shared state) Consumer
|
|
──────── ──────────────────── ────────
|
|
detects dialog change
|
|
↓
|
|
insert(camera, CameraTransitionIntent)
|
|
↓
|
|
camera_transition_intents
|
|
camera_intent_system reads it
|
|
sets up CameraTransition component
|
|
removes intent
|
|
```
|
|
|
|
An intent is ephemeral (one frame). The state change it triggers (a component mutation) persists.
|
|
|
|
## Intent Types
|
|
|
|
Intents are plain structs stored in `Storage<T>` on `World`, keyed by target entity:
|
|
|
|
```rust
|
|
// components/intent.rs
|
|
pub struct FollowPlayerIntent;
|
|
pub struct StopFollowingIntent;
|
|
pub struct CameraTransitionIntent { pub duration: f32 }
|
|
```
|
|
|
|
```rust
|
|
// world.rs
|
|
pub follow_player_intents: Storage<FollowPlayerIntent>,
|
|
pub stop_following_intents: Storage<StopFollowingIntent>,
|
|
pub camera_transition_intents: Storage<CameraTransitionIntent>,
|
|
```
|
|
|
|
Adding a new intent = one struct + one storage field. Nothing else changes.
|
|
|
|
## Producing Intents
|
|
|
|
Any code with access to the storage can submit. The entity is the target:
|
|
|
|
```rust
|
|
// Event handler wants camera to follow player — doesn't call camera functions
|
|
world.follow_player_intents.insert(camera_entity, FollowPlayerIntent);
|
|
|
|
// Dialog system detects state change — doesn't know how transitions work
|
|
world.camera_transition_intents.insert(camera_entity, CameraTransitionIntent { duration: 0.8 });
|
|
```
|
|
|
|
Producers don't import consumer modules. They only know about the intent type and the storage.
|
|
|
|
## Consuming Intents
|
|
|
|
A consumer system reads, acts, and removes:
|
|
|
|
```rust
|
|
pub fn camera_intent_system(world: &mut World) {
|
|
// 1. Read
|
|
let follow_entities = world.follow_player_intents.all();
|
|
for entity in follow_entities {
|
|
// 2. Act — all the follow setup logic lives here
|
|
start_camera_following(world, entity);
|
|
// 3. Remove (consume)
|
|
world.follow_player_intents.remove(entity);
|
|
}
|
|
// ... same for stop_following_intents, camera_transition_intents
|
|
}
|
|
```
|
|
|
|
The consumer owns the implementation. `start_camera_following` is a private function inside the camera module — no other module can call it directly.
|
|
|
|
## Why This Matters
|
|
|
|
**Decoupling.** The dialog system doesn't import camera functions. It inserts a `CameraTransitionIntent`. If the camera system changes how transitions work, the dialog system is unaffected.
|
|
|
|
**Multiple producers, same path.** Editor toggle, dialog state changes, and future cutscene systems all produce the same `CameraTransitionIntent`. They all go through the same processing. No special cases.
|
|
|
|
**Testability.** To test camera transitions: insert a `CameraTransitionIntent`, call `camera_intent_system`, check the result. No need to simulate dialog state or editor toggles.
|
|
|
|
**Additive behavior.** To add a new reaction to `StopFollowingIntent` (e.g., play a sound), write a new system that reads the intent before the camera system consumes it. No existing code changes.
|
|
|
|
## Execution Order
|
|
|
|
The main loop is a flat pipeline. Order encodes causality:
|
|
|
|
```
|
|
Input + intent generation (camera_input, player_input, dialog_transition_detect)
|
|
↓
|
|
Intent processing (camera_intent_system)
|
|
↓
|
|
Camera behavior (noclip, dialog_camera, follow, transition, ground_clamp)
|
|
↓
|
|
Editor overlay (UI only — not a game system)
|
|
↓
|
|
Fixed-step physics (state_machine, physics, triggers, dialog)
|
|
↓
|
|
Per-frame systems (state_machine, rotate, trees, spotlights, snow)
|
|
↓
|
|
Render
|
|
```
|
|
|
|
Moving a system changes the data flow. The order is the contract.
|
|
|
|
## Systems Also Self-Gate
|
|
|
|
Because intents express what should happen, systems naturally have nothing to do when their data is absent:
|
|
|
|
- `camera_follow_system` — no `FollowComponent` = no work
|
|
- `dialog_camera_system` — no active bubbles = no work
|
|
- `player_input_system` — camera not following = no player input
|
|
- `camera_noclip_system` — camera has `FollowComponent` = skip
|
|
|
|
The main loop doesn't branch on mode flags. Systems check their own data.
|
|
|
|
## When to Use Intents vs. Direct Mutation
|
|
|
|
| Situation | Approach |
|
|
|---|---|
|
|
| One system wants another to do something | Intent |
|
|
| A system updating its own components | Direct mutation |
|
|
| Per-frame continuous computation | Components + tick system |
|
|
| Persistent state (is the camera following?) | Component (`FollowComponent`) |
|
|
| One-shot request (start following) | Intent (`FollowPlayerIntent`) |
|
|
|
|
## Adding New Intents
|
|
|
|
1. Define the struct in `components/intent.rs`
|
|
2. Add a `Storage<T>` field to `World` (+ `new()` + `despawn()`)
|
|
3. Producers insert into the storage
|
|
4. A consumer system reads, acts, and removes
|
|
5. Done — no other code changes
|