From fbad967800719afc9757f1dddb5df966ffe6888f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 16:07:28 +0000 Subject: [PATCH 01/15] feat(states): add game states, run conditions and state-scoped entities Implements design/game-states.md. - ecs: `runIf` on `addSystem` and `addSystemGroup`, checked just before the system or group runs; a gated system isn't queried. - ecs: a built-in `firstSystemGroup` that runs before every other group. Groups ordered after it run at the start of the tick, before every other group, so a state's exit and enter groups run before input and gameplay. - states: new module with `createGameState`, `inState`/`onEnter`/`onExit` and `addStateScopedComponent`. - Docs: run conditions and the first group in the ECS guides, a new Game States guide and a game states demo. - design/game-states.md: records the start-of-tick placement the exit and enter groups need, and the scoped-removal group. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- AGENTS.md | 1 + CHANGELOG.md | 5 + design/game-states.md | 51 ++- documentation-site/docs/docs/ecs/system.md | 32 ++ documentation-site/docs/docs/ecs/world.md | 33 +- .../docs/docs/states/_category_.json | 8 + documentation-site/docs/docs/states/index.md | 149 ++++++++ .../docs/docs/states/state-scoped-entities.md | 63 ++++ documentation-site/src/data/demos.ts | 7 + .../pages/demos/game-states/_create-game.ts | 177 +++++++++ .../pages/demos/game-states/_create-label.ts | 41 ++ .../pages/demos/game-states/_demo-state.ts | 21 ++ .../pages/demos/game-states/_hud.system.ts | 20 + .../demos/game-states/_player.component.ts | 13 + .../pages/demos/game-states/_player.system.ts | 33 ++ .../src/pages/demos/game-states/_screens.ts | 133 +++++++ .../demos/game-states/_star.component.ts | 10 + .../pages/demos/game-states/_star.system.ts | 119 ++++++ .../demos/game-states/_state-input.system.ts | 41 ++ .../src/pages/demos/game-states/index.tsx | 68 ++++ package.json | 6 + src/ecs/ecs-world.test.ts | 266 +++++++++++++ src/ecs/ecs-world.ts | 185 ++++++++- src/ecs/index.ts | 1 + src/ecs/run-condition.ts | 14 + src/index.ts | 1 + src/states/components/index.ts | 1 + .../components/state-scoped-component.test.ts | 49 +++ .../components/state-scoped-component.ts | 87 +++++ src/states/game-state.test.ts | 353 ++++++++++++++++++ src/states/game-state.ts | 130 +++++++ src/states/index.ts | 3 + src/states/run-conditions.test.ts | 56 +++ src/states/run-conditions.ts | 47 +++ .../systems/state-scoped-removal-system.ts | 43 +++ src/states/systems/state-transition-system.ts | 53 +++ 36 files changed, 2295 insertions(+), 25 deletions(-) create mode 100644 documentation-site/docs/docs/states/_category_.json create mode 100644 documentation-site/docs/docs/states/index.md create mode 100644 documentation-site/docs/docs/states/state-scoped-entities.md create mode 100644 documentation-site/src/pages/demos/game-states/_create-game.ts create mode 100644 documentation-site/src/pages/demos/game-states/_create-label.ts create mode 100644 documentation-site/src/pages/demos/game-states/_demo-state.ts create mode 100644 documentation-site/src/pages/demos/game-states/_hud.system.ts create mode 100644 documentation-site/src/pages/demos/game-states/_player.component.ts create mode 100644 documentation-site/src/pages/demos/game-states/_player.system.ts create mode 100644 documentation-site/src/pages/demos/game-states/_screens.ts create mode 100644 documentation-site/src/pages/demos/game-states/_star.component.ts create mode 100644 documentation-site/src/pages/demos/game-states/_star.system.ts create mode 100644 documentation-site/src/pages/demos/game-states/_state-input.system.ts create mode 100644 documentation-site/src/pages/demos/game-states/index.tsx create mode 100644 src/ecs/run-condition.ts create mode 100644 src/states/components/index.ts create mode 100644 src/states/components/state-scoped-component.test.ts create mode 100644 src/states/components/state-scoped-component.ts create mode 100644 src/states/game-state.test.ts create mode 100644 src/states/game-state.ts create mode 100644 src/states/index.ts create mode 100644 src/states/run-conditions.test.ts create mode 100644 src/states/run-conditions.ts create mode 100644 src/states/systems/state-scoped-removal-system.ts create mode 100644 src/states/systems/state-transition-system.ts diff --git a/AGENTS.md b/AGENTS.md index c7da64531..002014fa1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,7 @@ this step by step for bug fixes. /physics # Physics integration /pooling # Object pooling /rendering # Rendering system + /states # Game states (createGameState), inState/onEnter/onExit run conditions, state-scoped entities /text # MSDF font atlas loading and text rendering /timer # Timer utilities /ui # Retained-mode UI (anchored rect tree layout, canvases, panels, labels, buttons, focus navigation, toggles, sliders, progress bars, dropdowns, layout groups, content size/aspect ratio fitters) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe2f7e2ce..a74e7e58d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +#### Added + +- **ecs:** Systems and system groups can run conditionally. Pass a `runIf` function of the world to `addSystem` or `addSystemGroup`, and the world checks it each tick just before the system or group would run, skipping it (and its query) when it returns `false`. `EcsWorld` also gains a built-in `firstSystemGroup` that runs before every other group of the tick. A group ordered `after` it joins the start of the tick, before every other group. Ordering a group `before` the first group throws, and so does ordering a group before a start-of-tick group, or a start-of-tick group after one that isn't +- **states:** New `@forge-game-engine/forge/states` module for a game's top-level states (menu, playing, paused, game over). `createGameState(world, initial)` returns a `GameState` whose `set` switches state at the start of the next tick. Its `exitGroup` and `enterGroup` run once per transition, before any other system, holding systems gated with `onExit`/`onEnter`; `inState` gates a system to some states. The initial state is entered on the first tick, and setting the current state again restarts it. `addStateScopedComponent` removes an entity when its state leaves one of `removeOnExit` or enters one of `removeOnEnter`. See the new Game States guide and demo + ## [0.25.8] - 2026-10-03 #### Fixed diff --git a/design/game-states.md b/design/game-states.md index 5daf6ac2c..6a533b517 100644 --- a/design/game-states.md +++ b/design/game-states.md @@ -2,7 +2,7 @@ | | | | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Status** | Draft, for review | +| **Status** | Implemented (Phases 1 and 2); the Galactic Journey migration (§9) follows the next release | | **Kind** | Feature | | **Found in** | Galactic Journey demo: `src/run/*` (`run.component.ts`'s `enteredPhase`/`leftPhase`, `run.system.ts`, `run-phases.ts`, `clear-run.system.ts`, `run-reset.system.ts`, `run-screens.system.ts`), and 15 `isInMenu`/`hasEntered`/`hasLeft` calls in 9 files | | **Engine version at time of writing** | `0.25.8` | @@ -135,6 +135,26 @@ default group (as `registerInputs` does) could run before a state transition. The first group makes "every system of the tick sees the same state" true. +"Every group runs after the first group" isn't enough for the state's exit +and enter groups (§4.3), though. They have to run right after the +transition and before every other group, but a group's order among groups +with no edge between them is insertion order, so `registerInputs`' +`input-update` group, added before the state, would run before the enter +group. So a group ordered `after` the first group (or after another group +that is) joins the **start of the tick**: the world orders it before every +group that isn't there, including groups added earlier or later. A +start-of-tick group ordered after a group that isn't at the start of the +tick throws, and so does a group ordered before a start-of-tick group it +isn't part of. That's the same layering Bevy gets from its fixed list of +main schedules, expressed with the group graph Forge already has. + +One consequence: start-of-tick groups run before `input-update`, so +`onEnter`/`onExit` systems read the previous tick's input. In Bevy, +`PreUpdate` (input) runs before `StateTransition`. Here the transition +comes first so that every system of the tick, input systems included, sees +the same state; a transition is requested by a system reacting to input, +so it applies on the next tick either way. + ### 4.3 States ```ts @@ -183,11 +203,16 @@ start of each tick, in this order: wins), setting `entered` and `exited` for this tick. 2. The `exitGroup` runs, so `onExit` systems can still read what the state is about to tear down. -3. State-scoped entities are removed (§4.4). +3. State-scoped entities are removed (§4.4), in a start-of-tick group of + their own between the exit and enter groups, so an exit system added + later can't end up after the removal. 4. The `enterGroup` runs, so `onEnter` systems set the new state up before any gameplay system sees it. 5. The rest of the tick. +The exit, removal and enter groups are start-of-tick groups (§4.2), gated +so they only run on ticks with a transition. + On the first tick, the initial state counts as entered: `entered` is `initial` and `onEnter` systems run, as Bevy runs the initial state's `OnEnter` at startup. The demo's `loading` phase goes. @@ -205,13 +230,15 @@ addStateScopedComponent(world, entity, { }); ``` -The transition system removes every entity whose state left one of its +The transition removes every entity whose state left one of its `removeOnExit` states, or entered one of its `removeOnEnter` states, at -step 3 above. Removal takes the entity's descendants with it -([`hierarchy-removal.md`](./hierarchy-removal.md)); removing a descendant -that was already removed is a no-op -([`generational-entity-ids.md`](./generational-entity-ids.md)). At least -one of the two lists must be non-empty. +step 3 above, with `world.removeEntity`. Once +[`hierarchy-removal.md`](./hierarchy-removal.md) ships, that takes the +entity's descendants with it, and removing a descendant that was already +removed is a no-op +([`generational-entity-ids.md`](./generational-entity-ids.md)). Until +then, removal takes only the scoped entity, as `removeEntity` does +everywhere else. At least one of the two lists must be non-empty. The demo's run leftovers stay on screen behind the end-of-run panels and are cleared when a new run starts or the menu comes up, so they use @@ -296,8 +323,12 @@ transition, holding systems with those conditions. **Rationale.** With (a), setup for a new state would interleave with gameplay systems in the same tick (a player spawned after the systems that should see it). Bevy runs its enter and exit schedules at the -transition, before any `Update` system, for that reason. Groups give -Forge the same order without a second scheduling concept. +transition, before any `Update` system, for that reason. Groups placed at +the start of the tick (§4.2) give Forge the same order without a second +scheduling concept. Bevy runs `OnEnter`/`OnExit` as schedules run on +demand from the transition; Forge's groups run in the world's normal order +and are skipped by a run condition on ticks without a transition, so no +"registered but not run by the loop" kind of group is needed. ### DL-3: Scoped removal on enter as well as on exit diff --git a/documentation-site/docs/docs/ecs/system.md b/documentation-site/docs/docs/ecs/system.md index 784c2dc8d..484ee5cb1 100644 --- a/documentation-site/docs/docs/ecs/system.md +++ b/documentation-site/docs/docs/ecs/system.md @@ -86,6 +86,38 @@ const system: EcsSystem<[Sprite]> = { `createRenderEcsSystem` uses this for a sprite's optional rotation, scale, and flip components. +## Run conditions + +A system that should only run on some ticks gets a run condition: a +function of the world that returns whether the system runs this tick. Pass +it as `runIf` when registering the system: + +```ts +world.addSystem(createSpawnerEcsSystem(time), { + runIf: () => !settings.isPaused, +}); +``` + +The world calls the condition each tick, just before the system would +run, so it sees what earlier systems of the same tick changed. When it +returns `false`, the system isn't queried and `update` isn't called. + +`addSystemGroup` takes a `runIf` too. A system in a gated group runs only +when the group's condition is true and then its own, and the group's +condition is checked once per tick for all of its systems. + +The most common run conditions come from [game states](../states/index.md): +`inState`, `onEnter` and `onExit`. + +A run condition decides when a system runs, never which entities it sees: +the system's `query` and `tags` stay the same. Keep conditions to cheap +reads (a flag, a state), since they run every tick. A gated system's +`cleanup` still runs when it's removed or the world stops. + +Prefer a run condition to an early `return` at the top of `update`. The +condition skips the query, and it shows when the system runs where the +system is registered, instead of inside its code. + ## Atomicity Treat each call to `update(world, queryResult)` as a single, focused update for the tick's batch of matched entities. Systems should perform short, deterministic operations and avoid long-running or blocking work inside `update`. diff --git a/documentation-site/docs/docs/ecs/world.md b/documentation-site/docs/docs/ecs/world.md index ca072da4c..2280372b8 100644 --- a/documentation-site/docs/docs/ecs/world.md +++ b/documentation-site/docs/docs/ecs/world.md @@ -34,6 +34,7 @@ world.removeEntity(entity); ``` This removes every component/tag the entity had and marks the id as available. +Entities parented to it (with `addParentComponent`) aren't removed with it. ## Adding a component to the entity @@ -198,6 +199,35 @@ used to serve. group; ordering systems across different groups is done by ordering their groups against each other instead. +### The first group and the start of the tick + +Every `EcsWorld` also has a `firstSystemGroup`, which runs before every other +group of the tick, however the other groups are ordered. Game state +transitions run there (see [Game States](../states/index.md)), so every +system of a tick sees the same state. Ordering a group `before` it throws. + +A group ordered `after: [world.firstSystemGroup]`, or after another group +that is, joins the start of the tick: it runs after the first group and +before every group that isn't there, including groups added earlier or +later. A game state's exit and enter groups are placed this way, so a +state is set up before any other system runs, even the input update group +`registerInputs` orders before the default group. + +```ts +const loadLevelGroup = createSystemGroup('load-level'); + +world.addSystemGroup(loadLevelGroup, { after: [world.firstSystemGroup] }); +``` + +A start-of-tick group can't be ordered after a group that isn't at the start +of the tick, and no other group can be ordered before it. Both throw. + +### Run conditions + +`addSystem` and `addSystemGroup` take a `runIf` function that decides, each +tick, whether the system or group runs. See +[System](./system.md#run-conditions). + ## Remove a system Remove a system with `removeSystem(system)`. @@ -216,7 +246,8 @@ it will still run as part of the current tick. The removal is only committed at Call `world.update()` to run the registered systems for a single frame. For each registered system, the world queries `query` (and `tags`) and invokes the system's `update` exactly once with the batch of matches, regardless of how -many entities matched (including zero). +many entities matched (including zero). A system or group whose `runIf` +returns `false` is skipped, and the skipped system isn't queried. In normal usage you don't call `update()` manually. The main loop in `Game` calls it for you every frame. Calling `update()` directly is useful for unit tests. diff --git a/documentation-site/docs/docs/states/_category_.json b/documentation-site/docs/docs/states/_category_.json new file mode 100644 index 000000000..173914ebf --- /dev/null +++ b/documentation-site/docs/docs/states/_category_.json @@ -0,0 +1,8 @@ +{ + "label": "Game States", + "position": 13, + "link": { + "type": "doc", + "id": "docs/states/index" + } +} diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md new file mode 100644 index 000000000..8d6496771 --- /dev/null +++ b/documentation-site/docs/docs/states/index.md @@ -0,0 +1,149 @@ +--- +sidebar_position: 1 +--- + +# Game States + +Most games move between a few top-level states: a menu, playing, paused, +game over. Most of their systems only make sense in some of them, and each +state has setup and teardown work: spawning the player when a round starts, +clearing the board when it ends. + +The `@forge-game-engine/forge/states` module covers both halves: + +- a [`GameState`](/Forge/docs/api/interfaces/GameState) that switches at the + start of a tick, +- run conditions (`inState`, `onEnter`, `onExit`) that decide which systems + run, +- [state-scoped entities](./state-scoped-entities.md), removed when the + state that owns them ends. + +The [game states demo](/Forge/demos/game-states) puts them together: a menu, +a round and a game-over screen with no state checks inside its systems and +no cleanup code. + +## Creating a state + +```ts +import { createGameState } from '@forge-game-engine/forge/states'; + +type Screen = 'menu' | 'playing' | 'paused' | 'gameOver'; + +const screen = createGameState(world, 'menu'); +``` + +`screen.current` is the current state. Call `screen.set('playing')` to +switch. The switch happens at the start of the next tick, not when `set` is +called, so every system of a tick sees the same state. If `set` is called +more than once in a tick, the last call wins. + +Pass the `GameState` to the systems that read or change it, the same way +`Time` is passed. + +## Running systems only in some states + +Register a system with `runIf: inState(...)` to run it only in those +states: + +```ts +import { inState } from '@forge-game-engine/forge/states'; + +world.addSystem(createEnemyAiEcsSystem(time), { + runIf: inState(screen, 'playing'), +}); +world.addSystem(createMenuInputEcsSystem(screen), { + runIf: inState(screen, 'menu', 'paused'), +}); +``` + +A gated system isn't queried or updated while its condition is false, so a +`paused` state that leaves gameplay systems out stops them where they are. +`Time` keeps running during the pause: a system that steps with +`time.deltaTimeInSeconds` resumes where it stopped, but one that compares +against `time.timeInSeconds` counts the pause as elapsed time. + +Gate a whole system group the same way, with `addSystemGroup`'s `runIf`. +See [System](../ecs/system.md#run-conditions) for how run conditions work. + +## Setting up and tearing down a state + +Work that happens once per transition (spawning the player, saving a high +score, showing a screen) goes in a system registered in the state's +`enterGroup` or `exitGroup`, gated with `onEnter` or `onExit`: + +```ts +import { onEnter, onExit } from '@forge-game-engine/forge/states'; + +world.addSystem(createSpawnPlayerEcsSystem(), { + group: screen.enterGroup, + runIf: onEnter(screen, 'playing'), +}); +world.addSystem(createSaveHighScoreEcsSystem(scores), { + group: screen.exitGroup, + runIf: onExit(screen, 'playing'), +}); +``` + +A transition runs at the start of a tick, in this order: + +1. The state switches. `screen.exited` is the state left and + `screen.entered` the state entered, for this tick only. +2. The `exitGroup` runs. Its systems can still read the entities of the + state being left. +3. [State-scoped entities](./state-scoped-entities.md) whose state ended are + removed. +4. The `enterGroup` runs, so the new state is set up before any other + system sees it. +5. Every other group of the world runs. + +On the first tick, the initial state counts as entered: `entered` is the +initial state and its `onEnter` systems run. Build a state's content in its +`onEnter` system rather than in setup code, and it's built the same way the +first time and every time after. + +Keep `onEnter` and `onExit` systems in the state's groups. In any other +group, they'd run later in the tick, after gameplay systems that should have +seen what they set up. + +### Restarting a state + +Setting the current state again re-enters it: its exit systems run, its +scoped entities are removed, and its enter systems run. `screen.set('playing')` +while playing restarts the round without a detour through another state. + +## Input and the start of the tick + +The state's transition and its exit and enter groups run before every other +group of the world, including the input update group `registerInputs` adds. +An `onEnter` or `onExit` system reads the previous tick's input. Act on +input in an ordinary system that calls `set`, as in the demo, and the +transition follows on the next tick. + +## Several worlds + +A `GameState` belongs to the world passed to `createGameState`, which +switches it and runs its exit and enter groups. A system in another world, +such as a UI overlay world, can still be gated on it with `inState`, since a +run condition only reads the state. A world that `Game` updates after the +owning world sees each transition in the same frame. + +## Mistakes to avoid + +Checking the state at the top of `update`: + +```ts +// Don't +update: (world, result) => { + if (screen.current !== 'playing') { + return; + } + // ... +}, +``` + +The system is still queried every tick, and the gate is hidden inside it. +Register it with `runIf: inState(screen, 'playing')` instead. + +Removing a round's entities kind by kind in an `onExit` system: scope them +to the state instead, so every entity a round creates goes with it, +including kinds added later. diff --git a/documentation-site/docs/docs/states/state-scoped-entities.md b/documentation-site/docs/docs/states/state-scoped-entities.md new file mode 100644 index 000000000..8d4ebf5c2 --- /dev/null +++ b/documentation-site/docs/docs/states/state-scoped-entities.md @@ -0,0 +1,63 @@ +--- +sidebar_position: 2 +--- + +# State-Scoped Entities + +An entity that belongs to a state, such as a menu's labels or the enemies of +a round, gets a +[`StateScopedEcsComponent`](/Forge/docs/api/interfaces/StateScopedEcsComponent). +When its state ends, the transition removes it, so no system has to find +and remove each kind of entity a state created. + +```ts +import { addStateScopedComponent } from '@forge-game-engine/forge/states'; + +addStateScopedComponent(world, enemy, { + state: screen, + removeOnExit: ['playing'], +}); +``` + +The entity is removed when `screen` leaves one of `removeOnExit`, or enters +one of `removeOnEnter`. At least one of the two lists has to name a state, +or `addStateScopedComponent` throws. + +Removal happens between the state's `exitGroup` and `enterGroup`, so +`onExit` systems still see the entities, and `onEnter` systems don't. + +## Removing on exit or on enter + +`removeOnExit` is the common case: the menu's labels go when the menu is +left. + +`removeOnEnter` is for content that should outlive its state. A round's +leftovers often stay on screen behind the game-over screen, and go when the +next round or the menu starts: + +```ts +addStateScopedComponent(world, star, { + state: screen, + removeOnEnter: ['playing', 'menu'], +}); +``` + +Because re-entering a state counts as entering it, `removeOnEnter: +['playing']` also clears the previous round when `screen.set('playing')` +restarts it. + +An entity scoped with `removeOnEnter` on the initial state and created +before the first tick is removed on that tick, since the initial state is +entered then. + +## Entities created at runtime + +Scope an entity where it's created. For particles, which their emitter +creates, add the component in the emitter's `onParticleSpawned` callback. + +## Children + +Removing a scoped entity removes that entity only. Removing an entity +doesn't remove the entities parented to it with `addParentComponent` (see +[World](../ecs/world.md#removing-an-entity-from-the-world)), so give each +child its own `StateScopedEcsComponent` with the same lists. diff --git a/documentation-site/src/data/demos.ts b/documentation-site/src/data/demos.ts index 0ce751686..af1e8d15f 100644 --- a/documentation-site/src/data/demos.ts +++ b/documentation-site/src/data/demos.ts @@ -33,6 +33,13 @@ export const demos: Demo[] = [ 'A drivable car built from rigid bodies, joints, springs and motors.', categories: ['physics', 'games'], }, + { + slug: 'game-states', + title: 'Game States', + description: + 'A menu, a round and a game-over screen, switched with a game state, run conditions and state-scoped entities.', + categories: ['ecs', 'games'], + }, { slug: 'ecs', title: 'ECS', diff --git a/documentation-site/src/pages/demos/game-states/_create-game.ts b/documentation-site/src/pages/demos/game-states/_create-game.ts new file mode 100644 index 000000000..d98020c42 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_create-game.ts @@ -0,0 +1,177 @@ +import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; +import { + actionResetTypes, + Axis1dAction, + buttonMoments, + KeyboardAxis1dBinding, + KeyboardInputSource, + KeyboardTriggerBinding, + keyCodes, + registerInputs, + TriggerAction, +} from '@forge-game-engine/forge/input'; +import { Random } from '@forge-game-engine/forge/math'; +import { + calculateVisibleWorldSize, + Color, + createCamera, + createCameraEcsSystem, + createImageSprite, + createRenderEcsSystem, +} from '@forge-game-engine/forge/rendering'; +import { + createGameState, + inState, + onEnter, +} from '@forge-game-engine/forge/states'; +import { + createTextShapingEcsSystem, + FontAtlasCache, +} from '@forge-game-engine/forge/text'; +import { createGame, Game } from '@forge-game-engine/forge/utilities'; +import { DEMO_VERTICAL_WORLD_UNITS } from '@site/src/utils/demo-camera'; +import { getAssetUrl } from '@site/src/utils/get-asset-url'; +import { DemoStateName, Round } from './_demo-state'; +import { createHudEcsSystem } from './_hud.system'; +import { createPlayerEcsSystem } from './_player.system'; +import { + createShowGameOverEcsSystem, + createShowMenuEcsSystem, + createStartRoundEcsSystem, +} from './_screens'; +import { + createGameOverInputEcsSystem, + createMenuInputEcsSystem, +} from './_state-input.system'; +import { + createStarEcsSystem, + createStarSpawnerEcsSystem, +} from './_star.system'; + +/** + * Builds the game states demo: a menu, a round of catching falling stars, + * and a game-over screen. Each state's setup runs once, in the state's + * enter group, gameplay systems only run while `playing`, and every entity + * is removed by its state-scoped component, so no system checks the state + * or cleans up after a round. + * @param fontAtlasUrl - The URL of the font atlas JSON to load. + * @returns The created game. + */ +export const createGameStatesGame = async ( + fontAtlasUrl: string, +): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + createCamera(world, { + isStatic: true, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + const fontAtlas = await new FontAtlasCache( + renderContext.imageCache, + ).getOrLoad(fontAtlasUrl); + + const starSprite = createImageSprite( + await renderContext.imageCache.getOrLoad(getAssetUrl('img/star_large.png')), + renderContext, + ); + starSprite.width = 36; + starSprite.height = 36; + + const basketSprite = createImageSprite( + await renderContext.imageCache.getOrLoad(getAssetUrl('img/White.png')), + renderContext, + ); + basketSprite.width = 110; + basketSprite.height = 16; + basketSprite.tintColor = new Color(0.3, 0.75, 1, 1); + + const visibleSize = calculateVisibleWorldSize( + renderContext.width, + renderContext.height, + DEMO_VERTICAL_WORLD_UNITS, + ); + const playArea = { + halfWidth: visibleSize.x / 2, + halfHeight: visibleSize.y / 2, + }; + + const moveInput = new Axis1dAction('move', null, actionResetTypes.noReset); + const playInput = new TriggerAction('play'); + const menuInput = new TriggerAction('menu'); + + const inputManager = registerInputs(world, time, { + axis1dActions: [moveInput], + triggerActions: [playInput, menuInput], + }); + const keyboard = new KeyboardInputSource(inputManager); + + keyboard.axis1dBindings.add( + new KeyboardAxis1dBinding( + moveInput, + keyCodes.arrowRight, + keyCodes.arrowLeft, + ), + ); + keyboard.axis1dBindings.add( + new KeyboardAxis1dBinding(moveInput, keyCodes.d, keyCodes.a), + ); + keyboard.triggerBindings.add( + new KeyboardTriggerBinding(playInput, keyCodes.space, buttonMoments.down), + ); + keyboard.triggerBindings.add( + new KeyboardTriggerBinding(menuInput, keyCodes.escape, buttonMoments.down), + ); + + const state = createGameState(world, 'menu'); + const round: Round = { score: 0, misses: 0, secondsUntilNextStar: 0 }; + + // Set-up for each state runs once, when it's entered. + world.addSystem(createShowMenuEcsSystem(state, fontAtlas), { + group: state.enterGroup, + runIf: onEnter(state, 'menu'), + }); + world.addSystem( + createStartRoundEcsSystem(state, round, fontAtlas, basketSprite, playArea), + { group: state.enterGroup, runIf: onEnter(state, 'playing') }, + ); + world.addSystem(createShowGameOverEcsSystem(state, round, fontAtlas), { + group: state.enterGroup, + runIf: onEnter(state, 'gameOver'), + }); + + // Each state's own systems only run in it. + world.addSystem(createMenuInputEcsSystem(state, playInput), { + runIf: inState(state, 'menu'), + }); + world.addSystem(createGameOverInputEcsSystem(state, playInput, menuInput), { + runIf: inState(state, 'gameOver'), + }); + + const isPlaying = inState(state, 'playing'); + + world.addSystem(createPlayerEcsSystem(moveInput, time), { runIf: isPlaying }); + world.addSystem( + createStarSpawnerEcsSystem( + state, + round, + starSprite, + playArea, + new Random(), + time, + ), + { runIf: isPlaying }, + ); + world.addSystem(createStarEcsSystem(state, round, playArea, time), { + runIf: isPlaying, + }); + world.addSystem(createHudEcsSystem(round), { runIf: isPlaying }); + + // These run in every state. + world.addSystem(createCameraEcsSystem(time)); + world.addSystem(createTransformEcsSystem()); + world.addSystem(createTextShapingEcsSystem(renderContext)); + world.addSystem(createRenderEcsSystem(renderContext)); + + return game; +}; diff --git a/documentation-site/src/pages/demos/game-states/_create-label.ts b/documentation-site/src/pages/demos/game-states/_create-label.ts new file mode 100644 index 000000000..14c0b28a8 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_create-label.ts @@ -0,0 +1,41 @@ +import { addPositionComponent } from '@forge-game-engine/forge/common'; +import { EcsWorld } from '@forge-game-engine/forge/ecs'; +import { Color } from '@forge-game-engine/forge/rendering'; +import { + addTextComponent, + FontAtlas, + textHorizontalAlignments, + textVerticalAlignments, +} from '@forge-game-engine/forge/text'; + +const labelWidth = 800; + +/** + * Creates a line of text centered on `y`. + * @returns The label's entity, for the caller to scope to a state. + */ +export function createLabel( + world: EcsWorld, + fontAtlas: FontAtlas, + text: string, + y: number, + size: number, + color: Color = Color.white, +): number { + const entity = world.createEntity(); + + addPositionComponent(world, entity, { local: { x: 0, y } }); + addTextComponent(world, entity, { + text, + fontAtlas, + size, + color, + maxWidth: labelWidth, + horizontalAlign: textHorizontalAlignments.center, + horizontalAlignPivot: 0.5, + verticalAlign: textVerticalAlignments.middle, + layer: 1, + }); + + return entity; +} diff --git a/documentation-site/src/pages/demos/game-states/_demo-state.ts b/documentation-site/src/pages/demos/game-states/_demo-state.ts new file mode 100644 index 000000000..a79a94109 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_demo-state.ts @@ -0,0 +1,21 @@ +import { GameState } from '@forge-game-engine/forge/states'; + +/** + * The demo's top-level states. `menu` waits for the player, `playing` runs + * a round, and `gameOver` shows the round's score over what it left behind. + */ +export type DemoStateName = 'menu' | 'playing' | 'gameOver'; + +export type DemoState = GameState; + +/** + * The current round's score, shared by the systems that play it and the + * game-over screen that reports it. Reset when a round starts. + */ +export interface Round { + score: number; + misses: number; + secondsUntilNextStar: number; +} + +export const maxMisses = 3; diff --git a/documentation-site/src/pages/demos/game-states/_hud.system.ts b/documentation-site/src/pages/demos/game-states/_hud.system.ts new file mode 100644 index 000000000..7d46e53cc --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_hud.system.ts @@ -0,0 +1,20 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { TextEcsComponent, textId } from '@forge-game-engine/forge/text'; +import { maxMisses, Round } from './_demo-state'; +import { hudId } from './_screens'; + +/** + * Writes the round's score into the score text. + */ +export const createHudEcsSystem = ( + round: Round, +): EcsSystem<[TextEcsComponent]> => ({ + name: 'hud', + query: [textId], + tags: [hudId], + update: (_world, { components: [texts] }) => { + for (const text of texts) { + text.text = `Caught ${round.score} Missed ${round.misses}/${maxMisses}`; + } + }, +}); diff --git a/documentation-site/src/pages/demos/game-states/_player.component.ts b/documentation-site/src/pages/demos/game-states/_player.component.ts new file mode 100644 index 000000000..12af84b71 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_player.component.ts @@ -0,0 +1,13 @@ +import { createComponentId } from '@forge-game-engine/forge/ecs'; + +/** + * Marks the basket the player moves to catch stars. + */ +export interface PlayerEcsComponent { + speed: number; + halfWidth: number; + minX: number; + maxX: number; +} + +export const playerId = createComponentId('player'); diff --git a/documentation-site/src/pages/demos/game-states/_player.system.ts b/documentation-site/src/pages/demos/game-states/_player.system.ts new file mode 100644 index 000000000..da1dacc93 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_player.system.ts @@ -0,0 +1,33 @@ +import { + PositionEcsComponent, + positionId, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { Axis1dAction } from '@forge-game-engine/forge/input'; +import { clamp } from '@forge-game-engine/forge/math'; +import { PlayerEcsComponent, playerId } from './_player.component'; + +/** + * Moves the basket left and right. Registered with `inState(state, + * 'playing')`, so the basket stops where it is once the round ends. + */ +export const createPlayerEcsSystem = ( + moveInput: Axis1dAction, + time: Time, +): EcsSystem<[PlayerEcsComponent, PositionEcsComponent]> => ({ + name: 'player', + query: [playerId, positionId], + update: (_world, { components: [players, positions] }) => { + for (let i = 0; i < players.length; i++) { + const { speed, minX, maxX } = players[i]; + const position = positions[i]; + + position.local.x = clamp( + position.local.x + moveInput.value * speed * time.deltaTimeInSeconds, + minX, + maxX, + ); + } + }, +}); diff --git a/documentation-site/src/pages/demos/game-states/_screens.ts b/documentation-site/src/pages/demos/game-states/_screens.ts new file mode 100644 index 000000000..37e57c637 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_screens.ts @@ -0,0 +1,133 @@ +import { addPositionComponent } from '@forge-game-engine/forge/common'; +import { createTagId, EcsSystem } from '@forge-game-engine/forge/ecs'; +import { + addSpriteComponent, + Color, + SpriteEcsComponent, +} from '@forge-game-engine/forge/rendering'; +import { addStateScopedComponent } from '@forge-game-engine/forge/states'; +import { FontAtlas } from '@forge-game-engine/forge/text'; +import { createLabel } from './_create-label'; +import { DemoState, maxMisses, Round } from './_demo-state'; +import { playerId } from './_player.component'; + +const accent = new Color(1, 0.8, 0.3, 1); +const muted = new Color(0.7, 0.75, 0.85, 1); + +/** + * Tags the text that shows the round's score while it's played. + */ +export const hudId = createTagId('hud'); + +/** + * Shows the title screen when the menu is entered. Its labels are scoped + * to the menu, so leaving it removes them. + */ +export const createShowMenuEcsSystem = ( + state: DemoState, + fontAtlas: FontAtlas, +): EcsSystem<[]> => ({ + name: 'show-menu', + query: [], + update: (world) => { + const labels = [ + createLabel(world, fontAtlas, 'STAR CATCHER', 60, 64, accent), + createLabel(world, fontAtlas, 'Press Space to play', -20, 24), + createLabel(world, fontAtlas, 'Arrow keys or A/D move', -60, 18, muted), + ]; + + for (const label of labels) { + addStateScopedComponent(world, label, { + state, + removeOnExit: ['menu'], + }); + } + }, +}); + +/** + * Sets up a round when `playing` is entered: resets the score, and creates + * the player's basket and the score text. Both stay on screen behind the + * game-over screen, so they're removed when the next round or the menu is + * entered, not when `playing` is left. + */ +export const createStartRoundEcsSystem = ( + state: DemoState, + round: Round, + fontAtlas: FontAtlas, + basketSprite: SpriteEcsComponent, + playArea: { halfWidth: number; halfHeight: number }, +): EcsSystem<[]> => ({ + name: 'start-round', + query: [], + update: (world) => { + round.score = 0; + round.misses = 0; + round.secondsUntilNextStar = 0; + + const basketHalfWidth = basketSprite.width / 2; + const basket = world.createEntity(); + + addPositionComponent(world, basket, { + local: { x: 0, y: -playArea.halfHeight + 50 }, + }); + addSpriteComponent(world, basket, basketSprite); + world.addComponent(basket, playerId, { + speed: 500, + halfWidth: basketHalfWidth, + minX: -playArea.halfWidth + basketHalfWidth, + maxX: playArea.halfWidth - basketHalfWidth, + }); + + const hud = createLabel(world, fontAtlas, '', playArea.halfHeight - 30, 22); + + world.addTag(hud, hudId); + + for (const entity of [basket, hud]) { + addStateScopedComponent(world, entity, { + state, + removeOnEnter: ['playing', 'menu'], + }); + } + }, +}); + +/** + * Shows the round's result when `gameOver` is entered, over whatever the + * round left on screen. + */ +export const createShowGameOverEcsSystem = ( + state: DemoState, + round: Round, + fontAtlas: FontAtlas, +): EcsSystem<[]> => ({ + name: 'show-game-over', + query: [], + update: (world) => { + const labels = [ + createLabel(world, fontAtlas, 'GAME OVER', 60, 56, accent), + createLabel( + world, + fontAtlas, + `You caught ${round.score} stars before missing ${maxMisses}`, + 0, + 22, + ), + createLabel( + world, + fontAtlas, + 'Space plays again, Escape goes to the menu', + -40, + 18, + muted, + ), + ]; + + for (const label of labels) { + addStateScopedComponent(world, label, { + state, + removeOnExit: ['gameOver'], + }); + } + }, +}); diff --git a/documentation-site/src/pages/demos/game-states/_star.component.ts b/documentation-site/src/pages/demos/game-states/_star.component.ts new file mode 100644 index 000000000..e53d6a977 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_star.component.ts @@ -0,0 +1,10 @@ +import { createComponentId } from '@forge-game-engine/forge/ecs'; + +/** + * A falling star the player tries to catch. + */ +export interface StarEcsComponent { + fallSpeed: number; +} + +export const starId = createComponentId('star'); diff --git a/documentation-site/src/pages/demos/game-states/_star.system.ts b/documentation-site/src/pages/demos/game-states/_star.system.ts new file mode 100644 index 000000000..b8aca3153 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_star.system.ts @@ -0,0 +1,119 @@ +import { + addPositionComponent, + PositionEcsComponent, + positionId, + Time, +} from '@forge-game-engine/forge/common'; +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { Random } from '@forge-game-engine/forge/math'; +import { + addSpriteComponent, + SpriteEcsComponent, +} from '@forge-game-engine/forge/rendering'; +import { addStateScopedComponent } from '@forge-game-engine/forge/states'; +import { DemoState, maxMisses, Round } from './_demo-state'; +import { PlayerEcsComponent, playerId } from './_player.component'; +import { StarEcsComponent, starId } from './_star.component'; + +const secondsBetweenStars = 0.6; +const catchHeight = 30; + +/** + * Drops a new star every `secondsBetweenStars`. Stars stay on screen behind + * the game-over screen, so they're scoped to be removed when the next round + * or the menu is entered. + */ +export const createStarSpawnerEcsSystem = ( + state: DemoState, + round: Round, + starSprite: SpriteEcsComponent, + playArea: { halfWidth: number; halfHeight: number }, + random: Random, + time: Time, +): EcsSystem<[]> => ({ + name: 'star-spawner', + query: [], + update: (world) => { + round.secondsUntilNextStar -= time.deltaTimeInSeconds; + + if (round.secondsUntilNextStar > 0) { + return; + } + + round.secondsUntilNextStar += secondsBetweenStars; + + const star = world.createEntity(); + const margin = starSprite.width; + + addPositionComponent(world, star, { + local: { + x: random.randomFloat( + -playArea.halfWidth + margin, + playArea.halfWidth - margin, + ), + y: playArea.halfHeight + margin, + }, + }); + addSpriteComponent(world, star, starSprite); + world.addComponent(star, starId, { + fallSpeed: random.randomFloat(150, 260), + }); + addStateScopedComponent(world, star, { + state, + removeOnEnter: ['playing', 'menu'], + }); + }, +}); + +/** + * Moves stars down, scores the ones that land in the basket and counts the + * ones that fall past it. The third miss ends the round. + */ +export const createStarEcsSystem = ( + state: DemoState, + round: Round, + playArea: { halfWidth: number; halfHeight: number }, + time: Time, +): EcsSystem<[StarEcsComponent, PositionEcsComponent]> => ({ + name: 'star', + query: [starId, positionId], + update: (world, { entities, components: [stars, positions] }) => { + const { + components: [players, playerPositions], + } = world.query<[PlayerEcsComponent, PositionEcsComponent]>([ + playerId, + positionId, + ]); + + for (let i = 0; i < entities.length; i++) { + const position = positions[i]; + + position.local.y -= stars[i].fallSpeed * time.deltaTimeInSeconds; + + const caught = players.some((player, p) => { + const basket = playerPositions[p].local; + + return ( + Math.abs(position.local.y - basket.y) < catchHeight && + Math.abs(position.local.x - basket.x) < player.halfWidth + ); + }); + + if (caught) { + round.score++; + world.removeEntity(entities[i]); + + continue; + } + + if (position.local.y < -playArea.halfHeight) { + round.misses++; + world.removeEntity(entities[i]); + } + } + + if (round.misses >= maxMisses) { + state.set('gameOver'); + } + }, +}); diff --git a/documentation-site/src/pages/demos/game-states/_state-input.system.ts b/documentation-site/src/pages/demos/game-states/_state-input.system.ts new file mode 100644 index 000000000..3178e48c2 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_state-input.system.ts @@ -0,0 +1,41 @@ +import { EcsSystem } from '@forge-game-engine/forge/ecs'; +import { TriggerAction } from '@forge-game-engine/forge/input'; +import { DemoState } from './_demo-state'; + +/** + * Starts a round from the menu. Registered with `inState(state, 'menu')`. + */ +export const createMenuInputEcsSystem = ( + state: DemoState, + playInput: TriggerAction, +): EcsSystem<[]> => ({ + name: 'menu-input', + query: [], + update: () => { + if (playInput.isTriggered) { + state.set('playing'); + } + }, +}); + +/** + * Plays again or goes back to the menu from the game-over screen. + * Registered with `inState(state, 'gameOver')`. + */ +export const createGameOverInputEcsSystem = ( + state: DemoState, + playInput: TriggerAction, + menuInput: TriggerAction, +): EcsSystem<[]> => ({ + name: 'game-over-input', + query: [], + update: () => { + if (playInput.isTriggered) { + state.set('playing'); + } + + if (menuInput.isTriggered) { + state.set('menu'); + } + }, +}); diff --git a/documentation-site/src/pages/demos/game-states/index.tsx b/documentation-site/src/pages/demos/game-states/index.tsx new file mode 100644 index 000000000..bba959e1a --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/index.tsx @@ -0,0 +1,68 @@ +import React, { JSX, useCallback } from 'react'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import { createGameStatesGame } from './_create-game'; +import gameCode from '!!raw-loader!./_create-game'; +import demoStateCode from '!!raw-loader!./_demo-state'; +import screensCode from '!!raw-loader!./_screens'; +import stateInputSystemCode from '!!raw-loader!./_state-input.system'; +import starSystemCode from '!!raw-loader!./_star.system'; +import playerSystemCode from '!!raw-loader!./_player.system'; +import hudSystemCode from '!!raw-loader!./_hud.system'; +import createLabelCode from '!!raw-loader!./_create-label'; + +import { Demo } from '@site/src/components/Demo'; +import { InteractionInstruction } from '@site/src/components/_InteractionInstruction'; +import { KeyboardKey } from '@site/src/components/_KeyboardKey'; + +export default function GameStates(): JSX.Element { + const { siteConfig } = useDocusaurusContext(); + const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; + const createGame = useCallback( + () => createGameStatesGame(fontAtlasUrl), + [fontAtlasUrl], + ); + + return ( + + } + text="Play, or play again." + /> + + + + + } + text="Move the basket." + /> + } + text="Back to the menu from the game-over screen." + /> + + } + codeFiles={[ + { name: 'game.ts', content: gameCode }, + { name: 'demo-state.ts', content: demoStateCode }, + { name: 'screens.ts', content: screensCode }, + { name: 'state-input.system.ts', content: stateInputSystemCode }, + { name: 'star.system.ts', content: starSystemCode }, + { name: 'player.system.ts', content: playerSystemCode }, + { name: 'hud.system.ts', content: hudSystemCode }, + { name: 'create-label.ts', content: createLabelCode }, + ]} + /> + ); +} diff --git a/package.json b/package.json index 572858bc3..289099990 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,12 @@ "default": "./dist/rendering/index.js" } }, + "./states": { + "import": { + "types": "./dist/states/index.d.ts", + "default": "./dist/states/index.js" + } + }, "./text": { "import": { "types": "./dist/text/index.d.ts", diff --git a/src/ecs/ecs-world.test.ts b/src/ecs/ecs-world.test.ts index 51b05f9ce..5c789c8ad 100644 --- a/src/ecs/ecs-world.test.ts +++ b/src/ecs/ecs-world.test.ts @@ -665,6 +665,272 @@ describe('EcsWorld', () => { }); }); + describe('run conditions', () => { + it('skips a system whose condition is false, without querying it', () => { + const world = new EcsWorld(); + const update = vi.fn(); + const querySpy = vi.spyOn(world, 'query'); + + world.addSystem( + { name: 'gated', query: [positionId], update }, + { runIf: () => false }, + ); + + world.update(); + + expect(update).not.toHaveBeenCalled(); + expect(querySpy).not.toHaveBeenCalled(); + }); + + it('runs a system whose condition is true', () => { + const world = new EcsWorld(); + const calls: string[] = []; + + world.addSystem(trackingSystem('gated', calls), { runIf: () => true }); + + world.update(); + + expect(calls).toEqual(['gated']); + }); + + it('passes the world to the condition', () => { + const world = new EcsWorld(); + const runIf = vi.fn(() => true); + + world.addSystem(trackingSystem('gated', []), { runIf }); + + world.update(); + + expect(runIf).toHaveBeenCalledWith(world); + }); + + it('checks the condition every tick', () => { + const world = new EcsWorld(); + const calls: string[] = []; + let enabled = false; + + world.addSystem(trackingSystem('gated', calls), { + runIf: () => enabled, + }); + + world.update(); + enabled = true; + world.update(); + + expect(calls).toEqual(['gated']); + }); + + it('checks the condition just before the system runs, after earlier systems of the tick', () => { + const world = new EcsWorld(); + const calls: string[] = []; + let enabled = false; + + world.addSystem({ + name: 'enabler', + query: [], + update: () => { + enabled = true; + }, + }); + world.addSystem(trackingSystem('gated', calls), { + runIf: () => enabled, + }); + + world.update(); + + expect(calls).toEqual(['gated']); + }); + + it('skips every system of a group whose condition is false, without checking their own', () => { + const world = new EcsWorld(); + const calls: string[] = []; + const systemRunIf = vi.fn(() => true); + const group = createSystemGroup('gated'); + + world.addSystemGroup(group, { runIf: () => false }); + world.addSystem(trackingSystem('a', calls), { + group, + runIf: systemRunIf, + }); + world.addSystem(trackingSystem('b', calls), { group }); + + world.update(); + + expect(calls).toEqual([]); + expect(systemRunIf).not.toHaveBeenCalled(); + }); + + it('runs a system only when both its group condition and its own are true', () => { + const world = new EcsWorld(); + const calls: string[] = []; + const group = createSystemGroup('gated'); + + world.addSystemGroup(group, { runIf: () => true }); + world.addSystem(trackingSystem('on', calls), { + group, + runIf: () => true, + }); + world.addSystem(trackingSystem('off', calls), { + group, + runIf: () => false, + }); + + world.update(); + + expect(calls).toEqual(['on']); + }); + + it('still calls cleanup for a gated system when it is removed', () => { + const world = new EcsWorld(); + const cleanup = vi.fn(); + const system: EcsSystem<[]> = { query: [], update: () => {}, cleanup }; + + world.addSystem(system, { runIf: () => false }); + world.update(); + world.removeSystem(system); + + expect(cleanup).toHaveBeenCalledWith(world); + }); + + it('still calls cleanup for a gated system when the world stops', () => { + const world = new EcsWorld(); + const cleanup = vi.fn(); + const group = createSystemGroup('gated'); + + world.addSystemGroup(group, { runIf: () => false }); + world.addSystem({ query: [], update: () => {}, cleanup }, { group }); + world.stop(); + + expect(cleanup).toHaveBeenCalledWith(world); + }); + + it('forgets the condition of a removed system', () => { + const world = new EcsWorld(); + const calls: string[] = []; + const system = trackingSystem('system', calls); + + world.addSystem(system, { runIf: () => false }); + world.removeSystem(system); + world.addSystem(system); + world.update(); + + expect(calls).toEqual(['system']); + }); + }); + + describe('first system group', () => { + it('runs before a group added earlier and ordered before the default group', () => { + const world = new EcsWorld(); + const calls: string[] = []; + const early = createSystemGroup('early'); + + world.addSystemGroup(early, { before: [world.defaultSystemGroup] }); + world.addSystem(trackingSystem('early', calls), { group: early }); + world.addSystem(trackingSystem('default', calls)); + world.addSystem(trackingSystem('first', calls), { + group: world.firstSystemGroup, + }); + + world.update(); + + expect(calls).toEqual(['first', 'early', 'default']); + }); + + it('throws when a group is ordered before it', () => { + const world = new EcsWorld(); + + expect(() => + world.addSystemGroup(createSystemGroup('too-early'), { + before: [world.firstSystemGroup], + }), + ).toThrow(/before the first group/); + }); + + it('throws when it is added as a group', () => { + const world = new EcsWorld(); + + expect(() => world.addSystemGroup(world.firstSystemGroup)).toThrow( + /built-in first group/, + ); + }); + + it('runs groups ordered after it before every other group, including ones added earlier or later', () => { + const world = new EcsWorld(); + const calls: string[] = []; + const input = createSystemGroup('input'); + const start = createSystemGroup('start'); + const startAfter = createSystemGroup('start-after'); + const late = createSystemGroup('late'); + + world.addSystemGroup(input, { before: [world.defaultSystemGroup] }); + world.addSystemGroup(start, { after: [world.firstSystemGroup] }); + world.addSystemGroup(startAfter, { after: [start] }); + world.addSystemGroup(late); + + world.addSystem(trackingSystem('late', calls), { group: late }); + world.addSystem(trackingSystem('default', calls)); + world.addSystem(trackingSystem('input', calls), { group: input }); + world.addSystem(trackingSystem('start-after', calls), { + group: startAfter, + }); + world.addSystem(trackingSystem('start', calls), { group: start }); + world.addSystem(trackingSystem('first', calls), { + group: world.firstSystemGroup, + }); + + world.update(); + + expect(calls).toEqual([ + 'first', + 'start', + 'start-after', + 'input', + 'default', + 'late', + ]); + }); + + it('throws when a start-of-tick group is ordered after a group that is not', () => { + const world = new EcsWorld(); + + expect(() => + world.addSystemGroup(createSystemGroup('start'), { + after: [world.firstSystemGroup, world.defaultSystemGroup], + }), + ).toThrow(/runs at the start of the tick/); + }); + + it('throws when a group is ordered before a start-of-tick group', () => { + const world = new EcsWorld(); + const start = createSystemGroup('start'); + + world.addSystemGroup(start, { after: [world.firstSystemGroup] }); + + expect(() => + world.addSystemGroup(createSystemGroup('other'), { before: [start] }), + ).toThrow(/runs at the start of the tick/); + }); + + it('keeps a registered group in its place when it is added again', () => { + const world = new EcsWorld(); + const calls: string[] = []; + const start = createSystemGroup('start'); + const other = createSystemGroup('other'); + + world.addSystemGroup(other); + world.addSystemGroup(start, { after: [world.firstSystemGroup] }); + world.addSystemGroup(other, { after: [start] }); + + world.addSystem(trackingSystem('other', calls), { group: other }); + world.addSystem(trackingSystem('default', calls)); + world.addSystem(trackingSystem('start', calls), { group: start }); + + world.update(); + + expect(calls).toEqual(['start', 'default', 'other']); + }); + }); + describe('onEntityRemoved', () => { it('raises onEntityRemoved with the entity id when removeEntity is called', () => { const world = new EcsWorld(); diff --git a/src/ecs/ecs-world.ts b/src/ecs/ecs-world.ts index 0f5f9ee55..dcbe66ccd 100644 --- a/src/ecs/ecs-world.ts +++ b/src/ecs/ecs-world.ts @@ -4,6 +4,7 @@ import { Stoppable, Updatable } from '../common/index.js'; import { DirectedAcyclicGraph, SparseSet } from '../utilities/index.js'; import { ParameterizedForgeEvent } from '../events/parameterized-forge-event.js'; import { EcsSystem } from './ecs-system.js'; +import { RunCondition } from './run-condition.js'; export interface QueryResult { entities: readonly number[]; @@ -28,6 +29,13 @@ export interface AddSystemOptions { * already be registered (via `addSystem`) in the same group as this one. */ after?: EcsSystem[]; + + /** + * Runs the system only on ticks where this returns `true`. Checked each + * tick just before the system would run, after its group's own `runIf`. + * A system that doesn't run isn't queried. + */ + runIf?: RunCondition; } export interface AddSystemGroupOptions { @@ -42,6 +50,12 @@ export interface AddSystemGroupOptions { * already be registered via `addSystemGroup`. */ after?: EcsSystemGroup[]; + + /** + * Runs the group's systems only on ticks where this returns `true`. + * Checked each tick just before the group would run. + */ + runIf?: RunCondition; } export class EcsWorld implements Updatable, Stoppable { @@ -59,7 +73,15 @@ export class EcsWorld implements Updatable, Stoppable { EcsSystemGroup >; private readonly _groupGraph: DirectedAcyclicGraph; + private readonly _firstSystemGroup: EcsSystemGroup; private readonly _defaultSystemGroup: EcsSystemGroup; + private readonly _startOfTickGroups: Set; + private readonly _restOfTickGroups: Set; + private readonly _systemRunConditions: Map< + EcsSystem, + RunCondition + >; + private readonly _groupRunConditions: Map; constructor() { this.onEntityRemoved = new ParameterizedForgeEvent('entityRemoved'); @@ -69,8 +91,31 @@ export class EcsWorld implements Updatable, Stoppable { this._groupGraph = new DirectedAcyclicGraph( (group) => group.name, ); + this._startOfTickGroups = new Set(); + this._restOfTickGroups = new Set(); + this._systemRunConditions = new Map(); + this._groupRunConditions = new Map(); + + this._firstSystemGroup = createSystemGroup('first'); + this._groupGraph.addNode(this._firstSystemGroup); + this._startOfTickGroups.add(this._firstSystemGroup); + this._defaultSystemGroup = createSystemGroup('default'); - this._groupGraph.addNode(this._defaultSystemGroup); + this.addSystemGroup(this._defaultSystemGroup); + } + + /** + * The group that runs before every other group of the tick, however the + * other groups are ordered. Game state transitions run here, so every + * system of a tick sees the same state. Ordering a group `before` it + * throws. + * + * A group ordered `after` it (or after another group that is) joins the + * start of the tick: it runs before every group that isn't, including + * groups added later. A game state's exit and enter groups work this way. + */ + get firstSystemGroup(): EcsSystemGroup { + return this._firstSystemGroup; } /** @@ -90,16 +135,43 @@ export class EcsWorld implements Updatable, Stoppable { } /** - * Registers a system group, ordering it relative to other groups. + * Registers a system group, ordering it relative to other groups. Every + * group runs after `firstSystemGroup`. A group ordered `after` the first + * group, or after another group that is, runs at the start of the tick, + * before every other group. * @param group - The group to register. - * @param options - `before`/`after` groups to order this group against. - * Every referenced group must already be registered. + * @param options - `before`/`after` groups to order this group against, + * and a `runIf` condition. Every referenced group must already be + * registered. + * @throws An error if `group` is the first group, if it's ordered before + * the first group, if a start-of-tick group is ordered after a group + * that isn't, or if a group that isn't is ordered before one that is. */ public addSystemGroup( group: EcsSystemGroup, options: AddSystemGroupOptions = {}, ): void { - const { before = [], after = [] } = options; + const { before = [], after = [], runIf } = options; + + if (group === this._firstSystemGroup) { + throw new Error( + `Unable to add system group "${group.name}", it's the world's built-in first group.`, + ); + } + + if (before.includes(this._firstSystemGroup)) { + throw new Error( + `Unable to order system group "${group.name}" before the first group, every group runs after it.`, + ); + } + + // A registered group keeps its place in the tick; a new one joins the + // start of the tick when it's ordered after a group that's there. + const isStartOfTick = this._groupGraph.has(group) + ? this._startOfTickGroups.has(group) + : after.some((afterGroup) => this._startOfTickGroups.has(afterGroup)); + + this._requireTickPosition(group, isStartOfTick, before, after); this._groupGraph.addNode(group); @@ -110,6 +182,12 @@ export class EcsWorld implements Updatable, Stoppable { for (const afterGroup of after) { this._groupGraph.addEdge(afterGroup, group); } + + this._placeGroupInTick(group, isStartOfTick); + + if (runIf) { + this._groupRunConditions.set(group, runIf); + } } /** @@ -127,6 +205,7 @@ export class EcsWorld implements Updatable, Stoppable { group = this._defaultSystemGroup, before = [], after = [], + runIf, } = options; if (!this._groupGraph.has(group)) { @@ -147,6 +226,10 @@ export class EcsWorld implements Updatable, Stoppable { systemGraph.addNode(system); this._groupBySystem.set(system, group); + if (runIf) { + this._systemRunConditions.set(system, runIf); + } + for (const beforeSystem of before) { this._requireSameGroup(system, beforeSystem, group); systemGraph.addEdge(system, beforeSystem); @@ -170,13 +253,31 @@ export class EcsWorld implements Updatable, Stoppable { this._groupBySystem.delete(system); } + this._systemRunConditions.delete(system); + system.cleanup?.(this); } + /** + * Runs one tick: every group in order, and every system of each group in + * order. A group or system whose `runIf` returns `false` is skipped + * without being queried. Conditions are checked just before the group or + * system would run, so they see what earlier systems of the tick did. + */ public update(): void { - for (const system of this._getOrderedSystems()) { - const results = this.query(system.query, system.tags); - system.update(this, results); + for (const { group, systems } of this._getOrderedGroups()) { + if (!this._shouldRun(this._groupRunConditions.get(group))) { + continue; + } + + for (const system of systems) { + if (!this._shouldRun(this._systemRunConditions.get(system))) { + continue; + } + + const results = this.query(system.query, system.tags); + system.update(this, results); + } } } @@ -416,18 +517,74 @@ export class EcsWorld implements Updatable, Stoppable { } } + private _getOrderedGroups(): { + group: EcsSystemGroup; + systems: EcsSystem[]; + }[] { + return this._groupGraph.topologicalSort().map((group) => ({ + group, + systems: this._systemGraphsByGroup.get(group)?.topologicalSort() ?? [], + })); + } + private _getOrderedSystems(): EcsSystem[] { - const orderedSystems: EcsSystem[] = []; + return this._getOrderedGroups().flatMap(({ systems }) => systems); + } + + private _shouldRun(runIf: RunCondition | undefined): boolean { + return runIf === undefined || runIf(this); + } - for (const group of this._groupGraph.topologicalSort()) { - const systemGraph = this._systemGraphsByGroup.get(group); + private _requireTickPosition( + group: EcsSystemGroup, + isStartOfTick: boolean, + before: readonly EcsSystemGroup[], + after: readonly EcsSystemGroup[], + ): void { + if (isStartOfTick) { + const laterGroup = after.find( + (afterGroup) => !this._startOfTickGroups.has(afterGroup), + ); - if (systemGraph) { - orderedSystems.push(...systemGraph.topologicalSort()); + if (laterGroup) { + throw new Error( + `Unable to order system group "${group.name}" after "${laterGroup.name}", "${group.name}" runs at the start of the tick (after the first group) and "${laterGroup.name}" doesn't.`, + ); } + + return; } - return orderedSystems; + const earlierGroup = before.find((beforeGroup) => + this._startOfTickGroups.has(beforeGroup), + ); + + if (earlierGroup) { + throw new Error( + `Unable to order system group "${group.name}" before "${earlierGroup.name}", "${earlierGroup.name}" runs at the start of the tick (after the first group) and "${group.name}" doesn't.`, + ); + } + } + + private _placeGroupInTick( + group: EcsSystemGroup, + isStartOfTick: boolean, + ): void { + if (isStartOfTick) { + this._startOfTickGroups.add(group); + + for (const laterGroup of this._restOfTickGroups) { + this._groupGraph.addEdge(group, laterGroup); + } + + return; + } + + this._restOfTickGroups.add(group); + + for (const earlierGroup of this._startOfTickGroups) { + this._groupGraph.addEdge(earlierGroup, group); + } } private _generateEntityId(): number { diff --git a/src/ecs/index.ts b/src/ecs/index.ts index 6d9b9a81e..df292ff75 100644 --- a/src/ecs/index.ts +++ b/src/ecs/index.ts @@ -2,3 +2,4 @@ export * from './ecs-component.js'; export * from './ecs-system.js'; export * from './ecs-system-group.js'; export * from './ecs-world.js'; +export * from './run-condition.js'; diff --git a/src/ecs/run-condition.ts b/src/ecs/run-condition.ts new file mode 100644 index 000000000..416081006 --- /dev/null +++ b/src/ecs/run-condition.ts @@ -0,0 +1,14 @@ +import type { EcsWorld } from './ecs-world.js'; + +/** + * Decides whether a system or system group runs this tick. Pass one as + * `runIf` to `EcsWorld.addSystem` or `EcsWorld.addSystemGroup`. The world + * calls it each tick just before the system or group would run. + * + * A run condition only reads state. It decides when a system runs, never + * which entities it matches: the system's `query` and `tags` stay the same. + * + * @param world - The world about to run the system or group. + * @returns Whether the system or group runs this tick. + */ +export type RunCondition = (world: EcsWorld) => boolean; diff --git a/src/index.ts b/src/index.ts index 706492cc5..b10f94576 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,3 +14,4 @@ export * from './timer/index.js'; export * from './ui/index.js'; export * from './utilities/index.js'; export * from './finite-state-machine/index.js'; +export * from './states/index.js'; diff --git a/src/states/components/index.ts b/src/states/components/index.ts new file mode 100644 index 000000000..d5cdaf9cc --- /dev/null +++ b/src/states/components/index.ts @@ -0,0 +1 @@ +export * from './state-scoped-component.js'; diff --git a/src/states/components/state-scoped-component.test.ts b/src/states/components/state-scoped-component.test.ts new file mode 100644 index 000000000..7b646decf --- /dev/null +++ b/src/states/components/state-scoped-component.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { EcsWorld } from '../../ecs/index.js'; +import { createGameState } from '../game-state.js'; +import { + addStateScopedComponent, + stateScopedId, +} from './state-scoped-component.js'; + +describe('addStateScopedComponent', () => { + it('attaches a component with an empty list for the transitions not given', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const entity = world.createEntity(); + + const component = addStateScopedComponent(world, entity, { + state, + removeOnExit: ['menu'], + }); + + expect(component).toEqual({ + state, + removeOnExit: ['menu'], + removeOnEnter: [], + }); + }); + + it('returns the attached component', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const entity = world.createEntity(); + + const component = addStateScopedComponent(world, entity, { + state, + removeOnEnter: ['menu'], + }); + + expect(world.getComponent(entity, stateScopedId)).toBe(component); + }); + + it('throws when neither list names a state', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const entity = world.createEntity(); + + expect(() => addStateScopedComponent(world, entity, { state })).toThrow( + /would never be removed/, + ); + }); +}); diff --git a/src/states/components/state-scoped-component.ts b/src/states/components/state-scoped-component.ts new file mode 100644 index 000000000..3708b8484 --- /dev/null +++ b/src/states/components/state-scoped-component.ts @@ -0,0 +1,87 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import { GameState } from '../game-state.js'; + +/** + * Fields of {@link StateScopedEcsComponent} with no sensible default; + * callers must always provide these. + */ +export interface StateScopedRequiredOptions { + /** + * The game state whose transitions remove the entity. + */ + state: GameState; +} + +/** + * Fields of {@link StateScopedEcsComponent} with a sensible default; callers + * may omit these, as long as one of them isn't empty. + */ +export interface StateScopedDefaultedOptions { + /** + * The entity is removed when `state` leaves any of these states. + */ + removeOnExit: readonly TName[]; + + /** + * The entity is removed when `state` enters any of these states. Use it + * for content that should stay on screen after its state ends, such as a + * round's leftovers behind a game-over screen, and go once the next round + * or the menu starts. + */ + removeOnEnter: readonly TName[]; +} + +/** + * ECS-style component interface that ties an entity's lifetime to a + * {@link GameState}. The state's transition removes the entity, between + * the state's `exitGroup` and `enterGroup`. + */ +export interface StateScopedEcsComponent + extends + StateScopedRequiredOptions, + StateScopedDefaultedOptions {} + +export const stateScopedId = + createComponentId('stateScoped'); + +const defaultStateScopedOptions: StateScopedDefaultedOptions = { + removeOnExit: [], + removeOnEnter: [], +}; + +/** + * Attaches a {@link StateScopedEcsComponent} to `entity`, so it's removed + * when `state` leaves one of `removeOnExit` or enters one of + * `removeOnEnter`. + * @param world - The ECS world `entity` belongs to. + * @param entity - The entity to attach the component to. + * @param options - The state, and the transitions that remove the entity. + * @returns The attached component. + * @throws An error if both `removeOnExit` and `removeOnEnter` are empty, + * since the entity would never be removed. + */ +export function addStateScopedComponent( + world: EcsWorld, + entity: number, + options: StateScopedRequiredOptions & + Partial>, +): StateScopedEcsComponent { + const component: StateScopedEcsComponent = { + ...defaultStateScopedOptions, + ...options, + }; + + if ( + component.removeOnExit.length === 0 && + component.removeOnEnter.length === 0 + ) { + throw new Error( + `Unable to add a state-scoped component to entity "${entity}", neither "removeOnExit" nor "removeOnEnter" lists a state, so it would never be removed.`, + ); + } + + world.addComponent(entity, stateScopedId, component); + + return component; +} diff --git a/src/states/game-state.test.ts b/src/states/game-state.test.ts new file mode 100644 index 000000000..95395d7a1 --- /dev/null +++ b/src/states/game-state.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from 'vitest'; +import { createSystemGroup, EcsSystem, EcsWorld } from '../ecs/index.js'; +import { + addStateScopedComponent, + stateScopedId, +} from './components/state-scoped-component.js'; +import { createGameState, GameState } from './game-state.js'; +import { inState, onEnter, onExit } from './run-conditions.js'; + +type Name = 'menu' | 'playing' | 'gameOver'; + +const recordingSystem = ( + calls: string[], + record: () => string, +): EcsSystem<[]> => ({ + query: [], + update: () => { + calls.push(record()); + }, +}); + +const snapshot = ( + state: GameState, +): { current: Name; entered: Name | null; exited: Name | null } => ({ + current: state.current, + entered: state.entered, + exited: state.exited, +}); + +const scopedEntities = (world: EcsWorld): readonly number[] => + world.query([stateScopedId]).entities; + +describe('createGameState', () => { + it('starts in the initial state', () => { + const state = createGameState(new EcsWorld(), 'menu'); + + expect(snapshot(state)).toEqual({ + current: 'menu', + entered: null, + exited: null, + }); + }); + + it('enters the initial state on the first tick', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + + world.update(); + + expect(snapshot(state)).toEqual({ + current: 'menu', + entered: 'menu', + exited: null, + }); + }); + + it('applies a requested transition at the start of the next tick, not immediately', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + + world.update(); + state.set('playing'); + + expect(state.current).toBe('menu'); + + world.update(); + + expect(snapshot(state)).toEqual({ + current: 'playing', + entered: 'playing', + exited: 'menu', + }); + }); + + it('reports entered and exited for exactly one tick', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + + world.update(); + state.set('playing'); + world.update(); + world.update(); + + expect(snapshot(state)).toEqual({ + current: 'playing', + entered: null, + exited: null, + }); + }); + + it('applies the last request when several are made in one tick', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + + world.update(); + state.set('playing'); + state.set('gameOver'); + world.update(); + + expect(snapshot(state)).toEqual({ + current: 'gameOver', + entered: 'gameOver', + exited: 'menu', + }); + }); + + it('re-enters the current state when it is requested', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'playing'); + const calls: string[] = []; + + world.addSystem( + recordingSystem(calls, () => 'exit'), + { group: state.exitGroup, runIf: onExit(state, 'playing') }, + ); + world.addSystem( + recordingSystem(calls, () => 'enter'), + { group: state.enterGroup, runIf: onEnter(state, 'playing') }, + ); + + world.update(); + state.set('playing'); + world.update(); + + expect(calls).toEqual(['enter', 'exit', 'enter']); + expect(snapshot(state)).toEqual({ + current: 'playing', + entered: 'playing', + exited: 'playing', + }); + }); + + it('applies a transition before every system of the tick, including groups ordered before the default group', () => { + const world = new EcsWorld(); + const early = createSystemGroup('early'); + + world.addSystemGroup(early, { before: [world.defaultSystemGroup] }); + + const state = createGameState(world, 'menu'); + const calls: string[] = []; + + world.addSystem( + recordingSystem(calls, () => `early sees ${state.current}`), + { group: early }, + ); + world.addSystem( + recordingSystem(calls, () => { + state.set('playing'); + + return `default sees ${state.current}`; + }), + ); + + world.update(); + world.update(); + + expect(calls).toEqual([ + 'early sees menu', + 'default sees menu', + 'early sees playing', + 'default sees playing', + ]); + }); + + it('runs exit systems, then scoped removal, then enter systems, before every other group', () => { + const world = new EcsWorld(); + const early = createSystemGroup('early'); + + // Added before the state, so only the state's groups running at the + // start of the tick keeps this group after them. + world.addSystemGroup(early, { before: [world.defaultSystemGroup] }); + + const state = createGameState(world, 'menu'); + const calls: string[] = []; + const scoped = world.createEntity(); + + addStateScopedComponent(world, scoped, { + state, + removeOnExit: ['menu'], + }); + + const scopedLabel = (): string => + scopedEntities(world).includes(scoped) ? 'present' : 'removed'; + + world.addSystem( + recordingSystem(calls, () => `gameplay sees ${state.current}`), + { group: early, runIf: onEnter(state, 'playing') }, + ); + world.addSystem( + recordingSystem(calls, () => `exit, scoped ${scopedLabel()}`), + { group: state.exitGroup, runIf: onExit(state, 'menu') }, + ); + world.addSystem( + recordingSystem(calls, () => `enter, scoped ${scopedLabel()}`), + { group: state.enterGroup, runIf: onEnter(state, 'playing') }, + ); + + world.update(); + state.set('playing'); + world.update(); + + expect(calls).toEqual([ + 'exit, scoped present', + 'enter, scoped removed', + 'gameplay sees playing', + ]); + }); + + it('runs onEnter systems for the initial state on the first tick', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const calls: string[] = []; + + world.addSystem( + recordingSystem(calls, () => 'enter menu'), + { group: state.enterGroup, runIf: onEnter(state, 'menu') }, + ); + world.addSystem( + recordingSystem(calls, () => 'exit'), + { group: state.exitGroup, runIf: onExit(state, 'menu') }, + ); + + world.update(); + world.update(); + + expect(calls).toEqual(['enter menu']); + }); + + it('gates systems on the current state with inState', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const calls: string[] = []; + + world.addSystem( + recordingSystem(calls, () => 'menu'), + { runIf: inState(state, 'menu') }, + ); + world.addSystem( + recordingSystem(calls, () => 'in game'), + { runIf: inState(state, 'playing', 'gameOver') }, + ); + + world.update(); + state.set('playing'); + world.update(); + state.set('gameOver'); + world.update(); + + expect(calls).toEqual(['menu', 'in game', 'in game']); + }); + + it('removes entities scoped to a state when it is left', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const entity = world.createEntity(); + + addStateScopedComponent(world, entity, { + state, + removeOnExit: ['menu'], + }); + + world.update(); + + expect(scopedEntities(world)).toContain(entity); + + state.set('playing'); + world.update(); + + expect(scopedEntities(world)).not.toContain(entity); + }); + + it('removes entities scoped to a state when it is entered', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'playing'); + const entity = world.createEntity(); + + world.update(); + + addStateScopedComponent(world, entity, { + state, + removeOnEnter: ['playing', 'menu'], + }); + + state.set('gameOver'); + world.update(); + + expect(scopedEntities(world)).toContain(entity); + + state.set('menu'); + world.update(); + + expect(scopedEntities(world)).not.toContain(entity); + }); + + it('removes an entity scoped to the initial state on enter on the first tick', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const entity = world.createEntity(); + + addStateScopedComponent(world, entity, { + state, + removeOnEnter: ['menu'], + }); + + world.update(); + + expect(scopedEntities(world)).not.toContain(entity); + }); + + it('leaves entities scoped to another game state alone', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const otherState = createGameState(world, 'menu'); + const entity = world.createEntity(); + + addStateScopedComponent(world, entity, { + state: otherState, + removeOnExit: ['menu'], + }); + + world.update(); + state.set('playing'); + world.update(); + + expect(scopedEntities(world)).toContain(entity); + + otherState.set('playing'); + world.update(); + + expect(scopedEntities(world)).not.toContain(entity); + }); + + it('runs no exit, removal or enter systems on ticks without a transition', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const calls: string[] = []; + + world.addSystem( + recordingSystem(calls, () => 'exit'), + { group: state.exitGroup }, + ); + world.addSystem( + recordingSystem(calls, () => 'enter'), + { group: state.enterGroup }, + ); + + world.update(); + calls.length = 0; + world.update(); + + expect(calls).toEqual([]); + }); +}); diff --git a/src/states/game-state.ts b/src/states/game-state.ts new file mode 100644 index 000000000..c56087323 --- /dev/null +++ b/src/states/game-state.ts @@ -0,0 +1,130 @@ +import { createSystemGroup, EcsSystemGroup, EcsWorld } from '../ecs/index.js'; +import { createStateScopedRemovalEcsSystem } from './systems/state-scoped-removal-system.js'; +import { + createStateTransitionEcsSystem, + GameStateStore, +} from './systems/state-transition-system.js'; + +/** + * A named top-level state of a game (loading, menu, playing, paused, game + * over, ...), switched at the start of a tick. Create one with + * {@link createGameState}, gate systems on it with `inState`, and set up or + * tear down a state with `onEnter`/`onExit` systems in its `enterGroup` and + * `exitGroup`. + * + * @typeParam TName - The names of the states. + */ +export interface GameState { + /** + * The current state. + */ + readonly current: TName; + + /** + * The state entered at the start of this tick, or `null` on ticks + * without a transition. On the first tick, it's the initial state. + */ + readonly entered: TName | null; + + /** + * The state left at the start of this tick, or `null` on ticks without a + * transition (and on the first tick). + */ + readonly exited: TName | null; + + /** + * Runs right after a transition, before the state's scoped entities are + * removed. Register `onExit` systems in it, so they can still read what + * the state is about to tear down. + */ + readonly exitGroup: EcsSystemGroup; + + /** + * Runs right after the state's scoped entities are removed, before every + * other system of the tick. Register `onEnter` systems in it, so a new + * state is set up before any gameplay system sees it. + */ + readonly enterGroup: EcsSystemGroup; + + /** + * Requests a transition, applied at the start of the next tick. If it's + * called more than once in a tick, the last call wins. Requesting the + * current state re-enters it: its exit and enter systems run again and + * its scoped entities are removed, which restarts it. + * @param next - The state to switch to. + */ + set(next: TName): void; +} + +/** + * Creates a {@link GameState} owned by `world`, starting in `initial`. + * + * Registers, in order at the start of every tick: a transition system in + * the world's `firstSystemGroup`, which applies the last `set` of the + * previous tick; the state's `exitGroup`; a system that removes the + * entities whose `StateScopedEcsComponent` matches the transition; and the + * state's `enterGroup`. Every other group of the world runs after them, so + * every system sees the same state for the whole tick. + * + * On the first tick, `initial` counts as entered, so its `onEnter` systems + * run. + * @param world - The world that applies the state's transitions and runs + * its exit and enter groups. + * @param initial - The state to start in. + * @returns The game state. + */ +export function createGameState( + world: EcsWorld, + initial: TName, +): GameState { + const store: GameStateStore = { + current: initial, + entered: null, + exited: null, + next: null, + }; + + const exitGroup = createSystemGroup('state-exit'); + const removalGroup = createSystemGroup('state-scoped-removal'); + const enterGroup = createSystemGroup('state-enter'); + + const state: GameState = { + get current(): TName { + return store.current; + }, + get entered(): TName | null { + return store.entered; + }, + get exited(): TName | null { + return store.exited; + }, + exitGroup, + enterGroup, + set(next: TName): void { + store.next = next; + }, + }; + + world.addSystem(createStateTransitionEcsSystem(store), { + group: world.firstSystemGroup, + }); + + world.addSystemGroup(exitGroup, { + after: [world.firstSystemGroup], + runIf: () => store.exited !== null, + }); + world.addSystemGroup(removalGroup, { + after: [exitGroup], + runIf: () => store.entered !== null, + }); + world.addSystemGroup(enterGroup, { + after: [removalGroup], + runIf: () => store.entered !== null, + }); + + world.addSystem(createStateScopedRemovalEcsSystem(state), { + group: removalGroup, + }); + + return state; +} diff --git a/src/states/index.ts b/src/states/index.ts new file mode 100644 index 000000000..31d8b68ef --- /dev/null +++ b/src/states/index.ts @@ -0,0 +1,3 @@ +export * from './components/index.js'; +export * from './game-state.js'; +export * from './run-conditions.js'; diff --git a/src/states/run-conditions.test.ts b/src/states/run-conditions.test.ts new file mode 100644 index 000000000..1c05a4249 --- /dev/null +++ b/src/states/run-conditions.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { EcsWorld } from '../ecs/index.js'; +import { createGameState } from './game-state.js'; +import { inState, onEnter, onExit } from './run-conditions.js'; + +type Name = 'menu' | 'playing' | 'gameOver'; + +describe('run conditions', () => { + it('inState is true while the state is one of the names', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + + expect(inState(state, 'menu')(world)).toBe(true); + expect(inState(state, 'playing', 'gameOver')(world)).toBe(false); + }); + + it('onEnter is true only on the tick one of the names is entered', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const enteringPlay = onEnter(state, 'playing', 'gameOver'); + + world.update(); + + expect(onEnter(state, 'menu')(world)).toBe(true); + expect(enteringPlay(world)).toBe(false); + + state.set('playing'); + world.update(); + + expect(enteringPlay(world)).toBe(true); + + world.update(); + + expect(enteringPlay(world)).toBe(false); + }); + + it('onExit is true only on the tick one of the names is left', () => { + const world = new EcsWorld(); + const state = createGameState(world, 'menu'); + const leavingMenu = onExit(state, 'menu'); + + world.update(); + + expect(leavingMenu(world)).toBe(false); + + state.set('playing'); + world.update(); + + expect(leavingMenu(world)).toBe(true); + expect(onExit(state, 'playing')(world)).toBe(false); + + world.update(); + + expect(leavingMenu(world)).toBe(false); + }); +}); diff --git a/src/states/run-conditions.ts b/src/states/run-conditions.ts new file mode 100644 index 000000000..40ed05381 --- /dev/null +++ b/src/states/run-conditions.ts @@ -0,0 +1,47 @@ +import { RunCondition } from '../ecs/index.js'; +import { GameState } from './game-state.js'; + +/** + * Creates a run condition that is true while `state` is in one of `names`. + * Gate a system or system group on it with `runIf` to run it only in those + * states. + * @param state - The game state to read. + * @param names - The states in which the condition is true. + * @returns The run condition. + */ +export function inState( + state: GameState, + ...names: TName[] +): RunCondition { + return () => names.includes(state.current); +} + +/** + * Creates a run condition that is true on the tick `state` enters one of + * `names`. Register systems with it in the state's `enterGroup`, so they + * run at the transition, before any other system of the tick. + * @param state - The game state to read. + * @param names - The states whose entry makes the condition true. + * @returns The run condition. + */ +export function onEnter( + state: GameState, + ...names: TName[] +): RunCondition { + return () => state.entered !== null && names.includes(state.entered); +} + +/** + * Creates a run condition that is true on the tick `state` leaves one of + * `names`. Register systems with it in the state's `exitGroup`, so they + * run at the transition, before the state's scoped entities are removed. + * @param state - The game state to read. + * @param names - The states whose exit makes the condition true. + * @returns The run condition. + */ +export function onExit( + state: GameState, + ...names: TName[] +): RunCondition { + return () => state.exited !== null && names.includes(state.exited); +} diff --git a/src/states/systems/state-scoped-removal-system.ts b/src/states/systems/state-scoped-removal-system.ts new file mode 100644 index 000000000..260b309a6 --- /dev/null +++ b/src/states/systems/state-scoped-removal-system.ts @@ -0,0 +1,43 @@ +import { EcsSystem } from '../../ecs/index.js'; +import { + StateScopedEcsComponent, + stateScopedId, +} from '../components/state-scoped-component.js'; +import { GameState } from '../game-state.js'; + +/** + * Creates the system that removes the entities scoped to `state` when it + * leaves one of their `removeOnExit` states or enters one of their + * `removeOnEnter` states. `createGameState` registers it between the + * state's `exitGroup` and `enterGroup`. + * @param state - The game state whose scoped entities the system removes. + * @returns The ECS system. + */ +export function createStateScopedRemovalEcsSystem( + state: GameState, +): EcsSystem<[StateScopedEcsComponent]> { + return { + name: 'state-scoped-removal', + query: [stateScopedId], + update: (world, { entities, components: [scopes] }): void => { + const { entered, exited } = state; + + for (let i = 0; i < entities.length; i++) { + const scope = scopes[i]; + + if (scope.state !== state) { + continue; + } + + const removedOnExit = + exited !== null && scope.removeOnExit.includes(exited); + const removedOnEnter = + entered !== null && scope.removeOnEnter.includes(entered); + + if (removedOnExit || removedOnEnter) { + world.removeEntity(entities[i]); + } + } + }, + }; +} diff --git a/src/states/systems/state-transition-system.ts b/src/states/systems/state-transition-system.ts new file mode 100644 index 000000000..29a81e040 --- /dev/null +++ b/src/states/systems/state-transition-system.ts @@ -0,0 +1,53 @@ +import { EcsSystem } from '../../ecs/index.js'; + +/** + * The part of a `GameState` its transition system writes. Kept out of + * the public interface so the transition system is the only writer of + * `current`, `entered` and `exited`. + */ +export interface GameStateStore { + current: TName; + entered: TName | null; + exited: TName | null; + /** The last state requested with `set` since the previous transition. */ + next: TName | null; +} + +/** + * Creates the system that applies a game state's requested transition. It + * runs in the world's `firstSystemGroup`, so a transition happens before + * any other system of the tick, and it's the only writer of the state's + * `current`, `entered` and `exited`. `createGameState` registers it. + * @param store - The state's writable fields. + * @returns The ECS system. + */ +export function createStateTransitionEcsSystem( + store: GameStateStore, +): EcsSystem<[]> { + let isFirstTick = true; + + return { + name: 'state-transition', + query: [], + update: (): void => { + store.entered = null; + store.exited = null; + + if (isFirstTick) { + isFirstTick = false; + store.entered = store.current; + + return; + } + + if (store.next === null) { + return; + } + + store.exited = store.current; + store.entered = store.next; + store.current = store.next; + store.next = null; + }, + }; +} From 2f2b02e230d17d552403b93b3f5859b233ba391e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:55:26 +0000 Subject: [PATCH 02/15] Merge remote-tracking branch 'origin/dev' into claude/serene-mendel-1j6xxo Brings in explicit font atlas URLs (#713) and HDR colors (#710). The game states demo loads the default font atlas from the package, as the other demos now do, since static/fonts is gone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- .../src/pages/demos/game-states/_create-game.ts | 16 +++++++++++----- .../src/pages/demos/game-states/index.tsx | 12 ++---------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/documentation-site/src/pages/demos/game-states/_create-game.ts b/documentation-site/src/pages/demos/game-states/_create-game.ts index aaa96c7e0..dc7560174 100644 --- a/documentation-site/src/pages/demos/game-states/_create-game.ts +++ b/documentation-site/src/pages/demos/game-states/_create-game.ts @@ -1,4 +1,5 @@ import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; +import defaultFontImageUrl from '@forge-game-engine/forge/fonts/default/default.png'; import { actionResetTypes, Axis1dAction, @@ -54,12 +55,9 @@ import { * enter group, gameplay systems only run while `playing`, and every entity * is removed by its state-scoped component, so no system checks the state * or cleans up after a round. - * @param fontAtlasUrl - The URL of the font atlas JSON to load. * @returns The created game. */ -export const createGameStatesGame = async ( - fontAtlasUrl: string, -): Promise => { +export const createGameStatesGame = async (): Promise => { const { game, world, renderContext, time } = createGame('demo-game'); const camera = createCamera(world, { @@ -69,7 +67,15 @@ export const createGameStatesGame = async ( const fontAtlas = await new FontAtlasCache( renderContext.imageCache, - ).getOrLoad(fontAtlasUrl); + ).getOrLoad({ + // Importing the JSON would give its parsed contents, so `new URL` asks + // webpack for its URL instead. + metricsUrl: new URL( + '@forge-game-engine/forge/fonts/default/default.json', + import.meta.url, + ).href, + imageUrl: defaultFontImageUrl, + }); const starSprite = createImageSprite( await renderContext.imageCache.getOrLoad(getAssetUrl('img/star_large.png')), diff --git a/documentation-site/src/pages/demos/game-states/index.tsx b/documentation-site/src/pages/demos/game-states/index.tsx index bba959e1a..8c1ee02e5 100644 --- a/documentation-site/src/pages/demos/game-states/index.tsx +++ b/documentation-site/src/pages/demos/game-states/index.tsx @@ -1,5 +1,4 @@ -import React, { JSX, useCallback } from 'react'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import React, { JSX } from 'react'; import { createGameStatesGame } from './_create-game'; import gameCode from '!!raw-loader!./_create-game'; import demoStateCode from '!!raw-loader!./_demo-state'; @@ -15,13 +14,6 @@ import { InteractionInstruction } from '@site/src/components/_InteractionInstruc import { KeyboardKey } from '@site/src/components/_KeyboardKey'; export default function GameStates(): JSX.Element { - const { siteConfig } = useDocusaurusContext(); - const fontAtlasUrl = `${siteConfig.baseUrl}fonts/default/default.json`; - const createGame = useCallback( - () => createGameStatesGame(fontAtlasUrl), - [fontAtlasUrl], - ); - return ( Date: Tue, 6 Oct 2026 19:17:42 +0000 Subject: [PATCH 03/15] docs(states): link the demo where the guide uses it, not in the intro Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/states/index.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index 8d6496771..d02ea1ce5 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -18,10 +18,6 @@ The `@forge-game-engine/forge/states` module covers both halves: - [state-scoped entities](./state-scoped-entities.md), removed when the state that owns them ends. -The [game states demo](/Forge/demos/game-states) puts them together: a menu, -a round and a game-over screen with no state checks inside its systems and -no cleanup code. - ## Creating a state ```ts @@ -116,7 +112,8 @@ while playing restarts the round without a detour through another state. The state's transition and its exit and enter groups run before every other group of the world, including the input update group `registerInputs` adds. An `onEnter` or `onExit` system reads the previous tick's input. Act on -input in an ordinary system that calls `set`, as in the demo, and the +input in an ordinary system that calls `set`, as the +[game states demo](/Forge/demos/game-states)'s input systems do, and the transition follows on the next tick. ## Several worlds From de59ff7444d4c3e5da6464c6364981677f00d520 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:18:29 +0000 Subject: [PATCH 04/15] docs(states): name the example state type GameStateName, not Screen Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/states/index.md | 30 +++++++++---------- .../docs/docs/states/state-scoped-entities.md | 8 ++--- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index d02ea1ce5..73f79877c 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -23,12 +23,12 @@ The `@forge-game-engine/forge/states` module covers both halves: ```ts import { createGameState } from '@forge-game-engine/forge/states'; -type Screen = 'menu' | 'playing' | 'paused' | 'gameOver'; +type GameStateName = 'menu' | 'playing' | 'paused' | 'gameOver'; -const screen = createGameState(world, 'menu'); +const gameState = createGameState(world, 'menu'); ``` -`screen.current` is the current state. Call `screen.set('playing')` to +`gameState.current` is the current state. Call `gameState.set('playing')` to switch. The switch happens at the start of the next tick, not when `set` is called, so every system of a tick sees the same state. If `set` is called more than once in a tick, the last call wins. @@ -45,10 +45,10 @@ states: import { inState } from '@forge-game-engine/forge/states'; world.addSystem(createEnemyAiEcsSystem(time), { - runIf: inState(screen, 'playing'), + runIf: inState(gameState, 'playing'), }); -world.addSystem(createMenuInputEcsSystem(screen), { - runIf: inState(screen, 'menu', 'paused'), +world.addSystem(createMenuInputEcsSystem(gameState), { + runIf: inState(gameState, 'menu', 'paused'), }); ``` @@ -71,19 +71,19 @@ score, showing a screen) goes in a system registered in the state's import { onEnter, onExit } from '@forge-game-engine/forge/states'; world.addSystem(createSpawnPlayerEcsSystem(), { - group: screen.enterGroup, - runIf: onEnter(screen, 'playing'), + group: gameState.enterGroup, + runIf: onEnter(gameState, 'playing'), }); world.addSystem(createSaveHighScoreEcsSystem(scores), { - group: screen.exitGroup, - runIf: onExit(screen, 'playing'), + group: gameState.exitGroup, + runIf: onExit(gameState, 'playing'), }); ``` A transition runs at the start of a tick, in this order: -1. The state switches. `screen.exited` is the state left and - `screen.entered` the state entered, for this tick only. +1. The state switches. `gameState.exited` is the state left and + `gameState.entered` the state entered, for this tick only. 2. The `exitGroup` runs. Its systems can still read the entities of the state being left. 3. [State-scoped entities](./state-scoped-entities.md) whose state ended are @@ -104,7 +104,7 @@ seen what they set up. ### Restarting a state Setting the current state again re-enters it: its exit systems run, its -scoped entities are removed, and its enter systems run. `screen.set('playing')` +scoped entities are removed, and its enter systems run. `gameState.set('playing')` while playing restarts the round without a detour through another state. ## Input and the start of the tick @@ -131,7 +131,7 @@ Checking the state at the top of `update`: ```ts // Don't update: (world, result) => { - if (screen.current !== 'playing') { + if (gameState.current !== 'playing') { return; } // ... @@ -139,7 +139,7 @@ update: (world, result) => { ``` The system is still queried every tick, and the gate is hidden inside it. -Register it with `runIf: inState(screen, 'playing')` instead. +Register it with `runIf: inState(gameState, 'playing')` instead. Removing a round's entities kind by kind in an `onExit` system: scope them to the state instead, so every entity a round creates goes with it, diff --git a/documentation-site/docs/docs/states/state-scoped-entities.md b/documentation-site/docs/docs/states/state-scoped-entities.md index 8d4ebf5c2..fe42a5f90 100644 --- a/documentation-site/docs/docs/states/state-scoped-entities.md +++ b/documentation-site/docs/docs/states/state-scoped-entities.md @@ -14,12 +14,12 @@ and remove each kind of entity a state created. import { addStateScopedComponent } from '@forge-game-engine/forge/states'; addStateScopedComponent(world, enemy, { - state: screen, + state: gameState, removeOnExit: ['playing'], }); ``` -The entity is removed when `screen` leaves one of `removeOnExit`, or enters +The entity is removed when `gameState` leaves one of `removeOnExit`, or enters one of `removeOnEnter`. At least one of the two lists has to name a state, or `addStateScopedComponent` throws. @@ -37,13 +37,13 @@ next round or the menu starts: ```ts addStateScopedComponent(world, star, { - state: screen, + state: gameState, removeOnEnter: ['playing', 'menu'], }); ``` Because re-entering a state counts as entering it, `removeOnEnter: -['playing']` also clears the previous round when `screen.set('playing')` +['playing']` also clears the previous round when `gameState.set('playing')` restarts it. An entity scoped with `removeOnEnter` on the initial state and created From 18ccb232df245d408702c82b10280d703e76e3df Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:19:13 +0000 Subject: [PATCH 05/15] docs(states): say what happens without runIf, drop the dependency-injection aside Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/states/index.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index 73f79877c..265edf6c2 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -33,12 +33,10 @@ switch. The switch happens at the start of the next tick, not when `set` is called, so every system of a tick sees the same state. If `set` is called more than once in a tick, the last call wins. -Pass the `GameState` to the systems that read or change it, the same way -`Time` is passed. - ## Running systems only in some states -Register a system with `runIf: inState(...)` to run it only in those +A system registered without `runIf` runs on every tick, whatever the +state. Register it with `runIf: inState(...)` to run it only in those states: ```ts From e3697d13964d6c19f060dcf4281a413c192115bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:20:16 +0000 Subject: [PATCH 06/15] docs(states): describe run conditions plainly instead of as gates Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- CHANGELOG.md | 2 +- documentation-site/docs/docs/ecs/system.md | 6 +++--- documentation-site/docs/docs/states/index.md | 10 +++++----- src/states/game-state.test.ts | 2 +- src/states/game-state.ts | 2 +- src/states/run-conditions.ts | 3 +-- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efa477606..13a4a6d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 #### Added - **ecs:** Systems and system groups can run conditionally. Pass a `runIf` function of the world to `addSystem` or `addSystemGroup`, and the world checks it each tick just before the system or group would run, skipping it (and its query) when it returns `false`. `EcsWorld` also gains a built-in `firstSystemGroup` that runs before every other group of the tick. A group ordered `after` it joins the start of the tick, before every other group. Ordering a group `before` the first group throws, and so does ordering a group before a start-of-tick group, or a start-of-tick group after one that isn't -- **states:** New `@forge-game-engine/forge/states` module for a game's top-level states (menu, playing, paused, game over). `createGameState(world, initial)` returns a `GameState` whose `set` switches state at the start of the next tick. Its `exitGroup` and `enterGroup` run once per transition, before any other system, holding systems gated with `onExit`/`onEnter`; `inState` gates a system to some states. The initial state is entered on the first tick, and setting the current state again restarts it. `addStateScopedComponent` removes an entity when its state leaves one of `removeOnExit` or enters one of `removeOnEnter`. See the new Game States guide and demo +- **states:** New `@forge-game-engine/forge/states` module for a game's top-level states (menu, playing, paused, game over). `createGameState(world, initial)` returns a `GameState` whose `set` switches state at the start of the next tick. Its `exitGroup` and `enterGroup` run once per transition, before any other system, holding systems that use `onExit`/`onEnter` as their `runIf`; `inState` runs a system only in some states. The initial state is entered on the first tick, and setting the current state again restarts it. `addStateScopedComponent` removes an entity when its state leaves one of `removeOnExit` or enters one of `removeOnEnter`. See the new Game States guide and demo - **rendering:** `getCameraView(world, camera, renderContext)` (and `computeCameraView(camera, position, renderContext)` for systems that already hold the camera's components) returns what a camera sees: the world area it shows (`bounds`, `size`, accounting for its position and zoom), its `pixelsPerUnit` in CSS pixels, and `worldToViewport`/`viewportToWorld` conversions to and from CSS pixels on the canvas. To place something drawn by one camera over something drawn by another, convert through the viewport: `hudView.viewportToWorld(gameView.worldToViewport(position))` - **text:** The engine's default font can be imported through a bundler from `@forge-game-engine/forge/fonts/default/default.json` and `@forge-game-engine/forge/fonts/default/default.png`, so you no longer need to copy it out of `node_modules` diff --git a/documentation-site/docs/docs/ecs/system.md b/documentation-site/docs/docs/ecs/system.md index 484ee5cb1..5d4e3e60c 100644 --- a/documentation-site/docs/docs/ecs/system.md +++ b/documentation-site/docs/docs/ecs/system.md @@ -102,7 +102,7 @@ The world calls the condition each tick, just before the system would run, so it sees what earlier systems of the same tick changed. When it returns `false`, the system isn't queried and `update` isn't called. -`addSystemGroup` takes a `runIf` too. A system in a gated group runs only +`addSystemGroup` takes a `runIf` too. A system in a group with one runs only when the group's condition is true and then its own, and the group's condition is checked once per tick for all of its systems. @@ -111,8 +111,8 @@ The most common run conditions come from [game states](../states/index.md): A run condition decides when a system runs, never which entities it sees: the system's `query` and `tags` stay the same. Keep conditions to cheap -reads (a flag, a state), since they run every tick. A gated system's -`cleanup` still runs when it's removed or the world stops. +reads (a flag, a state), since they run every tick. A system's `cleanup` +runs when it's removed or the world stops, whatever its run condition. Prefer a run condition to an early `return` at the top of `update`. The condition skips the query, and it shows when the system runs where the diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index 265edf6c2..821fe1810 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -50,20 +50,20 @@ world.addSystem(createMenuInputEcsSystem(gameState), { }); ``` -A gated system isn't queried or updated while its condition is false, so a +A system isn't queried or updated while its run condition is false, so a `paused` state that leaves gameplay systems out stops them where they are. `Time` keeps running during the pause: a system that steps with `time.deltaTimeInSeconds` resumes where it stopped, but one that compares against `time.timeInSeconds` counts the pause as elapsed time. -Gate a whole system group the same way, with `addSystemGroup`'s `runIf`. +`addSystemGroup` takes a `runIf` too, for a whole group of systems. See [System](../ecs/system.md#run-conditions) for how run conditions work. ## Setting up and tearing down a state Work that happens once per transition (spawning the player, saving a high score, showing a screen) goes in a system registered in the state's -`enterGroup` or `exitGroup`, gated with `onEnter` or `onExit`: +`enterGroup` or `exitGroup`, with `runIf: onEnter(...)` or `runIf: onExit(...)`: ```ts import { onEnter, onExit } from '@forge-game-engine/forge/states'; @@ -118,7 +118,7 @@ transition follows on the next tick. A `GameState` belongs to the world passed to `createGameState`, which switches it and runs its exit and enter groups. A system in another world, -such as a UI overlay world, can still be gated on it with `inState`, since a +such as a UI overlay world, can still use `inState` on it, since a run condition only reads the state. A world that `Game` updates after the owning world sees each transition in the same frame. @@ -136,7 +136,7 @@ update: (world, result) => { }, ``` -The system is still queried every tick, and the gate is hidden inside it. +The system is still queried every tick, and the check is hidden inside it. Register it with `runIf: inState(gameState, 'playing')` instead. Removing a round's entities kind by kind in an `onExit` system: scope them diff --git a/src/states/game-state.test.ts b/src/states/game-state.test.ts index 95395d7a1..73af65bbd 100644 --- a/src/states/game-state.test.ts +++ b/src/states/game-state.test.ts @@ -226,7 +226,7 @@ describe('createGameState', () => { expect(calls).toEqual(['enter menu']); }); - it('gates systems on the current state with inState', () => { + it('runs systems only in the states inState names', () => { const world = new EcsWorld(); const state = createGameState(world, 'menu'); const calls: string[] = []; diff --git a/src/states/game-state.ts b/src/states/game-state.ts index c56087323..fab011412 100644 --- a/src/states/game-state.ts +++ b/src/states/game-state.ts @@ -8,7 +8,7 @@ import { /** * A named top-level state of a game (loading, menu, playing, paused, game * over, ...), switched at the start of a tick. Create one with - * {@link createGameState}, gate systems on it with `inState`, and set up or + * {@link createGameState}, run systems only in some states with `inState`, and set up or * tear down a state with `onEnter`/`onExit` systems in its `enterGroup` and * `exitGroup`. * diff --git a/src/states/run-conditions.ts b/src/states/run-conditions.ts index 40ed05381..ed7ae2015 100644 --- a/src/states/run-conditions.ts +++ b/src/states/run-conditions.ts @@ -3,8 +3,7 @@ import { GameState } from './game-state.js'; /** * Creates a run condition that is true while `state` is in one of `names`. - * Gate a system or system group on it with `runIf` to run it only in those - * states. + * Pass it as `runIf` to run a system or system group only in those states. * @param state - The game state to read. * @param names - The states in which the condition is true. * @returns The run condition. From 8bcadf4c764754fe32431cf9328644199b50a288 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:21:23 +0000 Subject: [PATCH 07/15] docs(states): describe paused systems without implying they hold state, drop round-specific wording Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/states/index.md | 22 +++++++++---------- .../docs/docs/states/state-scoped-entities.md | 17 +++++++------- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index 821fe1810..c2f2e7465 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -6,8 +6,8 @@ sidebar_position: 1 Most games move between a few top-level states: a menu, playing, paused, game over. Most of their systems only make sense in some of them, and each -state has setup and teardown work: spawning the player when a round starts, -clearing the board when it ends. +state has setup and teardown work, such as spawning the player when playing +starts and removing it when playing ends. The `@forge-game-engine/forge/states` module covers both halves: @@ -50,11 +50,11 @@ world.addSystem(createMenuInputEcsSystem(gameState), { }); ``` -A system isn't queried or updated while its run condition is false, so a -`paused` state that leaves gameplay systems out stops them where they are. -`Time` keeps running during the pause: a system that steps with -`time.deltaTimeInSeconds` resumes where it stopped, but one that compares -against `time.timeInSeconds` counts the pause as elapsed time. +A system isn't queried or updated while its run condition is false. In a +`paused` state that leaves out the gameplay systems, the components those +systems write keep their values until the systems run again. `Time` keeps +running during a pause, so `time.timeInSeconds` includes the time spent +paused. `addSystemGroup` takes a `runIf` too, for a whole group of systems. See [System](../ecs/system.md#run-conditions) for how run conditions work. @@ -103,7 +103,7 @@ seen what they set up. Setting the current state again re-enters it: its exit systems run, its scoped entities are removed, and its enter systems run. `gameState.set('playing')` -while playing restarts the round without a detour through another state. +while in `playing` restarts it without switching to another state first. ## Input and the start of the tick @@ -139,6 +139,6 @@ update: (world, result) => { The system is still queried every tick, and the check is hidden inside it. Register it with `runIf: inState(gameState, 'playing')` instead. -Removing a round's entities kind by kind in an `onExit` system: scope them -to the state instead, so every entity a round creates goes with it, -including kinds added later. +Removing a state's entities in an `onExit` system, one query at a time: give +them a [`StateScopedEcsComponent`](./state-scoped-entities.md) instead, so +every entity created for the state is removed, including ones added later. diff --git a/documentation-site/docs/docs/states/state-scoped-entities.md b/documentation-site/docs/docs/states/state-scoped-entities.md index fe42a5f90..273eb85f8 100644 --- a/documentation-site/docs/docs/states/state-scoped-entities.md +++ b/documentation-site/docs/docs/states/state-scoped-entities.md @@ -4,8 +4,8 @@ sidebar_position: 2 # State-Scoped Entities -An entity that belongs to a state, such as a menu's labels or the enemies of -a round, gets a +An entity that belongs to a state, such as a menu's labels or the enemies +spawned while playing, gets a [`StateScopedEcsComponent`](/Forge/docs/api/interfaces/StateScopedEcsComponent). When its state ends, the transition removes it, so no system has to find and remove each kind of entity a state created. @@ -31,20 +31,19 @@ Removal happens between the state's `exitGroup` and `enterGroup`, so `removeOnExit` is the common case: the menu's labels go when the menu is left. -`removeOnEnter` is for content that should outlive its state. A round's -leftovers often stay on screen behind the game-over screen, and go when the -next round or the menu starts: +`removeOnEnter` is for entities that stay after their state ends. Enemies +left over from `playing` can stay on screen during `gameOver`, and be removed +when `playing` or `menu` is entered: ```ts -addStateScopedComponent(world, star, { +addStateScopedComponent(world, enemy, { state: gameState, removeOnEnter: ['playing', 'menu'], }); ``` -Because re-entering a state counts as entering it, `removeOnEnter: -['playing']` also clears the previous round when `gameState.set('playing')` -restarts it. +Re-entering a state counts as entering it, so `removeOnEnter: ['playing']` +also removes the entity when `gameState.set('playing')` restarts `playing`. An entity scoped with `removeOnEnter` on the initial state and created before the first tick is removed on that tick, since the initial state is From 86703cc006e59f9e09234fc2f4d57c8ab277c178 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:21:42 +0000 Subject: [PATCH 08/15] docs(states): drop the Time aside from the run conditions section Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/states/index.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index c2f2e7465..393a8b35f 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -52,9 +52,7 @@ world.addSystem(createMenuInputEcsSystem(gameState), { A system isn't queried or updated while its run condition is false. In a `paused` state that leaves out the gameplay systems, the components those -systems write keep their values until the systems run again. `Time` keeps -running during a pause, so `time.timeInSeconds` includes the time spent -paused. +systems write keep their values until the systems run again. `addSystemGroup` takes a `runIf` too, for a whole group of systems. See [System](../ecs/system.md#run-conditions) for how run conditions work. From f96f06e279e425059d01861f98c8e9bb7682e2ec Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:22:18 +0000 Subject: [PATCH 09/15] docs(states): drop the input section from the game states guide Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/states/index.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index 393a8b35f..0efaf6570 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -103,15 +103,6 @@ Setting the current state again re-enters it: its exit systems run, its scoped entities are removed, and its enter systems run. `gameState.set('playing')` while in `playing` restarts it without switching to another state first. -## Input and the start of the tick - -The state's transition and its exit and enter groups run before every other -group of the world, including the input update group `registerInputs` adds. -An `onEnter` or `onExit` system reads the previous tick's input. Act on -input in an ordinary system that calls `set`, as the -[game states demo](/Forge/demos/game-states)'s input systems do, and the -transition follows on the next tick. - ## Several worlds A `GameState` belongs to the world passed to `createGameState`, which From 1984ba8dcbb437a42984479db5ca088996b3572c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:23:19 +0000 Subject: [PATCH 10/15] docs(states): describe enter and exit systems as reacting to transitions, not setting up a state Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/ecs/world.md | 6 +++--- documentation-site/docs/docs/states/index.md | 20 ++++++++++---------- src/states/game-state.ts | 14 +++++++------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/documentation-site/docs/docs/ecs/world.md b/documentation-site/docs/docs/ecs/world.md index 15c84de07..02ed84d21 100644 --- a/documentation-site/docs/docs/ecs/world.md +++ b/documentation-site/docs/docs/ecs/world.md @@ -218,9 +218,9 @@ system of a tick sees the same state. Ordering a group `before` it throws. A group ordered `after: [world.firstSystemGroup]`, or after another group that is, joins the start of the tick: it runs after the first group and before every group that isn't there, including groups added earlier or -later. A game state's exit and enter groups are placed this way, so a -state is set up before any other system runs, even the input update group -`registerInputs` orders before the default group. +later. A game state's exit and enter groups are placed this way, so they +run before any other system, even the input update group `registerInputs` +orders before the default group. ```ts const loadLevelGroup = createSystemGroup('load-level'); diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index 0efaf6570..f489f4095 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -5,9 +5,9 @@ sidebar_position: 1 # Game States Most games move between a few top-level states: a menu, playing, paused, -game over. Most of their systems only make sense in some of them, and each -state has setup and teardown work, such as spawning the player when playing -starts and removing it when playing ends. +game over. Most of their systems only make sense in some of them, and some +work happens only when the game enters or leaves a state, such as spawning +the player when `playing` is entered. The `@forge-game-engine/forge/states` module covers both halves: @@ -57,7 +57,7 @@ systems write keep their values until the systems run again. `addSystemGroup` takes a `runIf` too, for a whole group of systems. See [System](../ecs/system.md#run-conditions) for how run conditions work. -## Setting up and tearing down a state +## Running systems when a state is entered or left Work that happens once per transition (spawning the player, saving a high score, showing a screen) goes in a system registered in the state's @@ -84,18 +84,18 @@ A transition runs at the start of a tick, in this order: state being left. 3. [State-scoped entities](./state-scoped-entities.md) whose state ended are removed. -4. The `enterGroup` runs, so the new state is set up before any other - system sees it. +4. The `enterGroup` runs, so what its systems create exists before any + other system runs. 5. Every other group of the world runs. On the first tick, the initial state counts as entered: `entered` is the -initial state and its `onEnter` systems run. Build a state's content in its -`onEnter` system rather than in setup code, and it's built the same way the -first time and every time after. +initial state and its `onEnter` systems run. Create the entities a state +needs in an `onEnter` system rather than before the first tick, and they're +created the same way every time the state is entered. Keep `onEnter` and `onExit` systems in the state's groups. In any other group, they'd run later in the tick, after gameplay systems that should have -seen what they set up. +seen what they created. ### Restarting a state diff --git a/src/states/game-state.ts b/src/states/game-state.ts index fab011412..0fc3eda89 100644 --- a/src/states/game-state.ts +++ b/src/states/game-state.ts @@ -8,9 +8,9 @@ import { /** * A named top-level state of a game (loading, menu, playing, paused, game * over, ...), switched at the start of a tick. Create one with - * {@link createGameState}, run systems only in some states with `inState`, and set up or - * tear down a state with `onEnter`/`onExit` systems in its `enterGroup` and - * `exitGroup`. + * {@link createGameState}, run systems only in some states with `inState`, + * and run systems when a state is entered or left with `onEnter`/`onExit` in + * its `enterGroup` and `exitGroup`. * * @typeParam TName - The names of the states. */ @@ -34,15 +34,15 @@ export interface GameState { /** * Runs right after a transition, before the state's scoped entities are - * removed. Register `onExit` systems in it, so they can still read what - * the state is about to tear down. + * removed. Register `onExit` systems in it, so they can still read the + * entities that are about to be removed. */ readonly exitGroup: EcsSystemGroup; /** * Runs right after the state's scoped entities are removed, before every - * other system of the tick. Register `onEnter` systems in it, so a new - * state is set up before any gameplay system sees it. + * other system of the tick. Register `onEnter` systems in it, so what + * they create exists before any other system runs. */ readonly enterGroup: EcsSystemGroup; From b7909eb568ce58fc742a659004203e6eae18cae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:23:59 +0000 Subject: [PATCH 11/15] docs(states): drop the several-worlds section from the game states guide Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/states/index.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index f489f4095..1f6461a99 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -103,14 +103,6 @@ Setting the current state again re-enters it: its exit systems run, its scoped entities are removed, and its enter systems run. `gameState.set('playing')` while in `playing` restarts it without switching to another state first. -## Several worlds - -A `GameState` belongs to the world passed to `createGameState`, which -switches it and runs its exit and enter groups. A system in another world, -such as a UI overlay world, can still use `inState` on it, since a -run condition only reads the state. A world that `Game` updates after the -owning world sees each transition in the same frame. - ## Mistakes to avoid Checking the state at the top of `update`: From 1f35bd5f49a12867d0b28071fe00c75e402a214d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:28:52 +0000 Subject: [PATCH 12/15] docs(skills): make document-feature produce isolated, literal technical documentation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- .claude/skills/document-feature/SKILL.md | 322 +++++++++++------------ CLAUDE.md | 16 +- 2 files changed, 163 insertions(+), 175 deletions(-) diff --git a/.claude/skills/document-feature/SKILL.md b/.claude/skills/document-feature/SKILL.md index 21b38a046..633a6320f 100644 --- a/.claude/skills/document-feature/SKILL.md +++ b/.claude/skills/document-feature/SKILL.md @@ -1,165 +1,158 @@ --- name: document-feature -description: Write or update a conceptual guide page in documentation-site/docs/docs for a new or changed Forge feature, focused on practical usage (common use cases, gotchas, performance notes, code smells to avoid) rather than restating the API surface. Also makes sure the public API has JSDoc for the auto-generated API reference. Use when a component, system, class, or module has been added or changed and needs user-facing documentation. +description: Write or update technical documentation in documentation-site/docs/docs for a Forge concept (a component, system, class, module or ECS mechanism). Produces correct, literal, to-the-point reference prose that documents one concept in isolation, with no flowery language, no sales pitch, no game- or demo-specific detail and no asides about other concepts. Also makes sure the public API has JSDoc for the generated API reference. Use whenever writing or editing any page or section under documentation-site/docs/docs, including JSDoc prose that ends up in the API reference. --- -# Document a feature - -Produces a handwritten guide page under `documentation-site/docs/docs/`. -These guides are a practical companion to the auto-generated API reference -(`documentation-site/docs/api/`, gitignored, built by typedoc from source -JSDoc), not a restatement of it. Never hand-edit anything under `docs/api/`, -fix the JSDoc in `/src` instead. - -## 1. Scope the feature - -- Find what changed: `git diff main...HEAD --stat` (or ask the user) to find - the relevant `/src/` directory. -- Read the tests (`*.test.ts`) and any usage in `/demo`. This is where the - "why" and "how it's actually used" lives, not just the constructor - signature. -- Check recent commit messages touching this code for context on tradeoffs, - perf fixes, or bugs that motivated the design. These often become the best - gotcha and performance notes. - -## 2. Ensure JSDoc exists (this feeds the API reference, not the guide) - -Per AGENTS.md, every public class/method/property needs a JSDoc comment with -`@param`, `@returns`, `@throws` as applicable. If the new API is missing -JSDoc, add it now, this is what `docs/api/` is generated from. If you edit -`/src`, follow CLAUDE.md verification (`npm run check-types`, `npm test`, -`npm run lint`) before finishing. - -The guide page in step 3 should assume this reference exists and link to it -rather than duplicating it. - -## 3. What belongs in the guide - -The guide's job is to help someone use the feature correctly and avoid -mistakes, not to enumerate its API surface (the generated reference already -does that). Favor: - -- **Common use cases**: the problem the feature solves, framed around a - realistic scenario, e.g. "use `applyForce` for a continuous push like - wind or thrust, use `applyImpulse` for an instantaneous hit like a - collision or jump." -- **Gotchas**: non-obvious behavior the reader's own code must account for, - e.g. ordering requirements (system registration order), units and - coordinate conventions, what a lookup returns at edge values (a miss - returns `undefined` rather than throwing; a disabled entity is skipped). - The test is whether it changes what code the reader writes, not whether - it's interesting. -- **Performance notes**: anything that affects cost at scale and changes - what the reader should do, e.g. "preload up front, not mid-gameplay." - Mine recent perf-related commits and code comments for this, but state - the actionable consequence, not the mechanism. -- **Common mistakes / code smells**: a short "don't do this" example paired - with "do this instead" and a one-line reason. -- **A realistic worked example**: the feature used in context (inside a - system, alongside related components), not just a bare constructor call. - -### Tone: factual, not narrative - -Write declarative sentences a reader can scan for the fact they need, not -prose that reassures or editorializes. Cut adjectives/adverbs that describe -quality rather than behavior ("gracefully", "cleanly", "nicely", -"powerful", "simply", "just") — if a sentence still means the same thing -with the adjective removed, remove it. State what happens; don't comment on -how good the way it happens is. - -### Document the interface, not the internals - -The reader needs to know what the feature does from the outside: inputs, -outputs, return values, when a promise rejects, what triggers a thrown -error. They do not need _how_ it's implemented internally, and they do not -need reassurance about implementation quality: - -- Wrong: "`load` rejects with a descriptive error if the JSON is malformed, - so a broken file never surfaces as a confusing `NaN` downstream." (this - narrates an internal design decision and vouches for its own quality) -- Right: nothing at all, if the mere fact that malformed input throws isn't - something the reader has to code around. If it genuinely changes what the - reader should do (e.g. "wrap `load` in try/catch when the source isn't - your own build output"), say that specific, actionable thing and stop. - -The same applies to caching mechanics, internal data structures, or how an -error is caught and re-thrown: these are implementation facts you likely -learned while building the feature, not things the reader needs. - -### Only cross-link genuine is-a relationships - -Link to a shared parent concept the feature is a real instance of (a -specific `AssetCache` implementation → the `AssetCache` doc), since the -reader benefits from knowing the general contract once. Do **not** link to -or mention a sibling/adjacent feature just because it's similar, reuses the -same pattern, or was what you read as an implementation reference while -building this one (e.g. don't mention `ImageCache` while documenting a new, -unrelated cache just because you modeled the new cache's code after it). -Citing a sibling assumes the reader already knows that sibling — usually -false — and adds cognitive load for no payoff. Before adding any -cross-reference, ask: would a reader who has never seen the other thing -still get full value from this link? If the answer is "they'd have to go -learn the other thing first," cut it. - -### What does NOT belong in the guide - -- Full constructor signatures, parameter lists, or return types. Link to the - API reference instead. -- A "Properties" section that just restates field declarations. -- Method-by-method walkthroughs that mirror the class's public interface. -- Implementation narration: how errors are caught internally, how caching - is implemented under the hood, why an internal design choice was made. -- A "Guides in this section" list on a module's `index.md` if the sidebar - nav already lists those same pages — it's pure duplication. -- Reassurance that the engine does its job well ("fails descriptively", - "handles this gracefully"). State the observable behavior; skip the - editorializing about how well it's done. - -If you find yourself transcribing JSDoc into the guide, stop, that -information already lives in the generated reference. Link to it using the -site's base URL, following the existing pattern in -`docs/ecs/game.md`: -`[RigidBody](/Forge/docs/api/classes/RigidBody)`, -`[applyForce](/Forge/docs/api/classes/RigidBody#applyforce)`. - -## 4. Find or create the guide page - -Guide pages live at `documentation-site/docs/docs//.md`, where -`` matches the `/src/` folder name (`ecs`, `physics`, -`lifecycle`, `animations`, `common`, `utils`, ...). - -- **Module folder already exists** (e.g. `physics/`): add a new - `kebab-case.md` file for the feature, or extend an existing page if the - feature is a small addition to a concept already documented there. -- **Module folder doesn't exist yet**: create it with: - - `_category_.json` - - `index.md`, a short overview of the module (1+ paragraphs). Don't add a - "Guides in this section" list of links to the other pages in the - folder — the sidebar nav already lists them; a manual list is pure - duplication that goes stale the moment a page is renamed. - - the new topic page(s) +# Write technical documentation + +A guide page under `documentation-site/docs/docs/` explains one concept: what +it is, how to use it, and how it behaves. It sits next to the generated API +reference (`documentation-site/docs/api/`, built by typedoc from JSDoc, never +hand-edited), so it doesn't restate signatures. + +The bar is **correct, to the point, technical**. Every sentence states a fact +about the concept that a reader needs in order to use it. Anything else is +removed. + +## 1. Learn the concept before writing + +- Read the source, the tests (`*.test.ts`) and the JSDoc of the concept you + are documenting. The tests are the specification of its behavior. +- Write down, for yourself, the facts a user needs: what it is, how to create + or register it, its options and their defaults, what happens when an option + is omitted, when it runs or takes effect (order, timing), what it reads and + writes, what throws, and its limits. +- Every claim in the page must be one of these facts, checked against the + code. If you can't point to the code or test that makes a sentence true, + don't write it. + +## 2. Ensure JSDoc exists + +Every public class, function, property and option needs JSDoc with `@param`, +`@returns` and `@throws` as applicable (see AGENTS.md). The same writing rules +below apply to JSDoc. Fixing a reference entry means fixing the JSDoc in +`/src`; after editing `/src`, run the CLAUDE.md verification steps. + +## 3. Writing rules + +### Document the concept in isolation + +- The page is about one concept. Don't explain neighboring concepts (time, + input, worlds, rendering, dependency injection, general programming + practice) or how they interact with this one, unless the concept's own + behavior can't be stated without them. When the reader needs another + concept, link its page in one clause; don't summarize it. +- Don't describe a particular game, demo or genre. Examples use generic, + self-explanatory names. "A round", "the game-over screen", "the star + catcher" are implementation details of someone's game, not of the engine. +- Don't open with, promote or narrate a demo. A demo is linked, if at all, + once, where the text refers to a specific piece of its code. +- Don't compare the concept to other engines, earlier versions or + alternatives. History and rationale belong in `/design` and the changelog. + +### Be literal and correct + +- State behavior as plain fact in the present tense: "`update` isn't called + while the run condition returns `false`." +- Use the operation's real name: "is removed", "is not queried", "is set to + `null`", "runs before". Don't use metaphors or figurative verbs: no + "gated", "stops them where they are", "hands off", "lives in", "sees", + "wakes up", "takes care of", "under the hood". +- Describe things as what they are in the ECS model. Systems are stateless: + they don't pause, resume, stop "where they are" or remember anything. + Components hold data. A state value doesn't need "setting up"; systems run + when it changes. Don't attribute state, location, intent or feelings to + code. +- No marketing or filler: no "powerful", "seamless", "simply", "just", + "easily", "elegant", "out of the box", "puts it all together", no + rhetorical questions, no exclamations, no "Note that" or "It's worth + mentioning". Don't vouch for quality ("handles this gracefully"). +- No en-dashes or em-dashes. + +### Be to the point + +- Open with one or two sentences that define the concept in technical terms: + what it is and what it does. No scene-setting, no list of what the page + will cover. +- One fact per sentence where possible. Cut a sentence if the reader can use + the concept correctly without it. +- State defaults and omissions explicitly: what happens when an option, a + `runIf`, a group or a field is left out. +- State ordering and timing exactly when the concept has any: in which order + things run, on which tick a change takes effect, what is visible to whom + and when. +- State error conditions the caller must handle or avoid. +- Don't add an example for something that isn't specific to the concept + (passing a value to a factory, importing a module, writing a lambda). + +### Examples + +- Minimal: only the code needed to show the concept, with real imports from + the published package path (`@forge-game-engine/forge/`, no + relative paths, no `.js`). +- Names say what the value is: `gameState` and `GameStateName`, not `screen` + and `Screen`; `enemy`, not `star`. +- Show the call, then state its effect in prose. Don't narrate the example + line by line. +- A "don't do this" example is allowed only for a misuse of this concept's + own API that compiles and silently does the wrong thing. Show it, say what + goes wrong, show the fix. + +### What never goes in a page + +- Full signatures, parameter lists, property lists or method-by-method + walkthroughs (link the API reference: + `[RigidBody](/Forge/docs/api/classes/RigidBody)`). +- Implementation narration: internal data structures, caching, how errors + are produced. +- Content about another concept (see "in isolation" above). +- A "Guides in this section" list on an `index.md` (the sidebar lists them). + +## 4. Page structure + +1. `# Title`: the concept's name in Title Case (`# Game States`, + `# Run Conditions`), not a use-case slogan. +2. Definition: one or two sentences. +3. One `##` section per thing the reader does, in the order they do it: + create it, use it, configure it. Each section states the rule, shows a + minimal example, then states the resulting behavior (order, timing, + defaults, errors). +4. Constraints and limits, if any, in their own section. + +Sections that don't apply are left out. A short page is fine. + +## 5. Final pass + +Read the page sentence by sentence and delete or rewrite each one that fails +any of these: + +- **True?** You've checked it against the code or tests. +- **About this concept?** Not about time, input, worlds, a demo or a game. +- **Literal?** No metaphor, personification or adjective that judges quality. +- **Needed?** A reader can't use the concept correctly without it. +- **Precise?** Names the exact API, value, order or condition. + +## 6. Mechanics + +### Location + +Pages live at `documentation-site/docs/docs//.md`, where +`` matches the `/src/` folder name. Add a section to an +existing page when the concept is part of one already documented there +(e.g. run conditions in `ecs/system.md`). A new module folder gets a +`_category_.json` and an `index.md`. ### Page conventions -- Optional frontmatter `sidebar_position: N` to order pages within a folder - (used in `ecs`, `lifecycle`, `common`), pick a number after the existing - siblings. -- `# Title` in Title Case, naming the use case or concept (not necessarily - the class name), e.g. `# Applying Forces`, not `# RigidBody`. -- Code blocks use ` ```ts ` or ` ```typescript ` and import from the - **published package path** (no relative paths, no `.js`), e.g.: +- Optional frontmatter `sidebar_position: N` orders pages within a folder; + pick a number after the existing siblings. +- Cross-link guide pages with relative markdown links + (`[World](../ecs/world.md)`), and API reference and demo pages with the + `/Forge` base URL (`/Forge/docs/api/...`, `/Forge/demos/...`). - ```ts - import { RigidBody } from '@forge-game-engine/forge/physics'; - ``` +### `_category_.json` -- Cross-link related guide pages with relative markdown links, e.g. - `[World docs](./world.md)`. -- Do not use any en-dashes or em-dashes. - -### `_category_.json` shapes - -For a module with an `index.md` overview page: +With an `index.md` overview page: ```json { @@ -169,7 +162,7 @@ For a module with an `index.md` overview page: } ``` -For a module without one yet (sidebar lists pages directly): +Without one: ```json { @@ -178,18 +171,13 @@ For a module without one yet (sidebar lists pages directly): } ``` -Check sibling `_category_.json` files under `documentation-site/docs/docs/` -to pick a `position` that doesn't collide. - -## 5. Wire it up - -- Double-check the new page's filename/heading reads sensibly in the - autogenerated sidebar (`docsSidebar` uses `{ type: 'autogenerated', dirName: '.' }`). +Pick a `position` that no sibling `_category_.json` under +`documentation-site/docs/docs/` uses. -## 6. Verify +### Verify -- `cd documentation-site && npm run start` and visit the new page, confirm - it renders, the sidebar entry appears in the right place, and any internal - links resolve. -- If `/src` was edited in step 2, run the full CLAUDE.md verification suite - (`npm run check-types`, `npm test`, `npm run lint`) from the repo root. +- `npx prettier --check` and `npm run cspell` on the changed files. +- From `documentation-site/`, `npm run build`: it fails on broken links. +- `npm run start` and open the page: it renders and sits in the right place + in the sidebar. +- If `/src` was edited, run the full CLAUDE.md verification suite. diff --git a/CLAUDE.md b/CLAUDE.md index e730336ed..1c5f7b798 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,14 +37,14 @@ section has the reasoning. Project skills live in `.claude/skills/`. Use the matching one before you start the task: -| Task | Skill | -| ---------------------------------------------------------- | ------------------------ | -| Fixing a bug, defect, regression or wrong behavior | `fix-defect` | -| Adding an ECS component | `create-component` | -| Adding a major feature (needs a docs-site demo) | `add-feature-demo` | -| Writing or updating a guide in `documentation-site/docs` | `document-feature` | -| Adding a Playwright test for rendering, input or game loop | `write-e2e-test` | -| Designing or planning a new feature or large change | `create-design-document` | +| Task | Skill | +| -------------------------------------------------------------- | ------------------------ | +| Fixing a bug, defect, regression or wrong behavior | `fix-defect` | +| Adding an ECS component | `create-component` | +| Adding a major feature (needs a docs-site demo) | `add-feature-demo` | +| Writing or updating documentation in `documentation-site/docs` | `document-feature` | +| Adding a Playwright test for rendering, input or game loop | `write-e2e-test` | +| Designing or planning a new feature or large change | `create-design-document` | ## Before implementing From 96488195d24be400571b26ca59e852bc1caab218 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 19:28:53 +0000 Subject: [PATCH 13/15] docs(states): rewrite the game states docs as isolated technical reference Applies the document-feature skill to the game states guide, the state-scoped entities guide, the run conditions section of the System guide, the first group section of the World guide, and the GameState and firstSystemGroup JSDoc. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- documentation-site/docs/docs/ecs/system.md | 38 +++--- documentation-site/docs/docs/ecs/world.md | 24 ++-- documentation-site/docs/docs/states/index.md | 128 +++++++----------- .../docs/docs/states/state-scoped-entities.md | 61 ++++----- src/ecs/ecs-world.ts | 12 +- src/states/game-state.ts | 19 ++- 6 files changed, 111 insertions(+), 171 deletions(-) diff --git a/documentation-site/docs/docs/ecs/system.md b/documentation-site/docs/docs/ecs/system.md index 5d4e3e60c..b643e099d 100644 --- a/documentation-site/docs/docs/ecs/system.md +++ b/documentation-site/docs/docs/ecs/system.md @@ -88,35 +88,29 @@ and flip components. ## Run conditions -A system that should only run on some ticks gets a run condition: a -function of the world that returns whether the system runs this tick. Pass -it as `runIf` when registering the system: +A run condition is a function `(world: EcsWorld) => boolean`. Passed as +`runIf` to `addSystem`, it decides on each tick whether the system runs: ```ts -world.addSystem(createSpawnerEcsSystem(time), { - runIf: () => !settings.isPaused, -}); +world.addSystem(spawnerSystem, { runIf: () => !settings.isPaused }); ``` -The world calls the condition each tick, just before the system would -run, so it sees what earlier systems of the same tick changed. When it -returns `false`, the system isn't queried and `update` isn't called. +- The world calls the condition each tick, immediately before the system + would run, so it reads values written by earlier systems in the same tick. +- When it returns `false`, the system isn't queried and `update` isn't + called. +- A system registered without `runIf` runs on every tick. -`addSystemGroup` takes a `runIf` too. A system in a group with one runs only -when the group's condition is true and then its own, and the group's -condition is checked once per tick for all of its systems. +`addSystemGroup` also takes `runIf`. The group's condition is called once +per tick, before the group runs. When it returns `false`, none of the +group's systems run and their own conditions aren't called. A system in a +group runs when both conditions return `true`. -The most common run conditions come from [game states](../states/index.md): -`inState`, `onEnter` and `onExit`. +A run condition doesn't change a system's `query` or `tags`. `cleanup` runs +when the system is removed or the world stops, whatever its run condition. -A run condition decides when a system runs, never which entities it sees: -the system's `query` and `tags` stay the same. Keep conditions to cheap -reads (a flag, a state), since they run every tick. A system's `cleanup` -runs when it's removed or the world stops, whatever its run condition. - -Prefer a run condition to an early `return` at the top of `update`. The -condition skips the query, and it shows when the system runs where the -system is registered, instead of inside its code. +`inState`, `onEnter` and `onExit` create run conditions from a +[game state](../states/index.md). ## Atomicity diff --git a/documentation-site/docs/docs/ecs/world.md b/documentation-site/docs/docs/ecs/world.md index 02ed84d21..63ffde77c 100644 --- a/documentation-site/docs/docs/ecs/world.md +++ b/documentation-site/docs/docs/ecs/world.md @@ -208,19 +208,16 @@ used to serve. group; ordering systems across different groups is done by ordering their groups against each other instead. -### The first group and the start of the tick +### The first group -Every `EcsWorld` also has a `firstSystemGroup`, which runs before every other -group of the tick, however the other groups are ordered. Game state -transitions run there (see [Game States](../states/index.md)), so every -system of a tick sees the same state. Ordering a group `before` it throws. +`world.firstSystemGroup` runs before every other group on every tick. +Ordering a group `before` it throws. -A group ordered `after: [world.firstSystemGroup]`, or after another group -that is, joins the start of the tick: it runs after the first group and -before every group that isn't there, including groups added earlier or -later. A game state's exit and enter groups are placed this way, so they -run before any other system, even the input update group `registerInputs` -orders before the default group. +A group registered with `after` containing `firstSystemGroup`, or containing +another group registered that way, is a start-of-tick group. Start-of-tick +groups run after the first group and before every other group, including +groups registered earlier or later. A `GameState`'s `exitGroup` and +`enterGroup` are start-of-tick groups. ```ts const loadLevelGroup = createSystemGroup('load-level'); @@ -228,8 +225,9 @@ const loadLevelGroup = createSystemGroup('load-level'); world.addSystemGroup(loadLevelGroup, { after: [world.firstSystemGroup] }); ``` -A start-of-tick group can't be ordered after a group that isn't at the start -of the tick, and no other group can be ordered before it. Both throw. +Registering a start-of-tick group with `after` containing a group that isn't +a start-of-tick group throws. Registering any other group with `before` +containing a start-of-tick group throws. ### Run conditions diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index 1f6461a99..c072f8a59 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -4,122 +4,86 @@ sidebar_position: 1 # Game States -Most games move between a few top-level states: a menu, playing, paused, -game over. Most of their systems only make sense in some of them, and some -work happens only when the game enters or leaves a state, such as spawning -the player when `playing` is entered. +A [`GameState`](/Forge/docs/api/interfaces/GameState) holds one value from a +fixed set of state names and changes it at the start of a tick. Run +conditions created from a `GameState` decide which systems run in each +state, and which run on the tick a state is entered or left. -The `@forge-game-engine/forge/states` module covers both halves: - -- a [`GameState`](/Forge/docs/api/interfaces/GameState) that switches at the - start of a tick, -- run conditions (`inState`, `onEnter`, `onExit`) that decide which systems - run, -- [state-scoped entities](./state-scoped-entities.md), removed when the - state that owns them ends. - -## Creating a state +## Creating a game state ```ts import { createGameState } from '@forge-game-engine/forge/states'; -type GameStateName = 'menu' | 'playing' | 'paused' | 'gameOver'; +type GameStateName = 'menu' | 'playing' | 'paused'; const gameState = createGameState(world, 'menu'); ``` -`gameState.current` is the current state. Call `gameState.set('playing')` to -switch. The switch happens at the start of the next tick, not when `set` is -called, so every system of a tick sees the same state. If `set` is called -more than once in a tick, the last call wins. +`createGameState` registers the systems and groups that apply transitions in +`world`. `gameState.current` is the current state. + +## Changing state + +`gameState.set(name)` requests a transition. The transition is applied at +the start of the next tick, in `world.firstSystemGroup`, which runs before +every other group, so every system in a tick reads the same `current`. When `set` is called more than once in a +tick, the last call is applied. -## Running systems only in some states +Calling `set` with the current state re-enters it. The transition runs the +same steps as a transition to another state. -A system registered without `runIf` runs on every tick, whatever the -state. Register it with `runIf: inState(...)` to run it only in those -states: +## Running a system in some states + +`inState(gameState, ...names)` returns a run condition that is `true` while +`current` is one of `names`. Pass it as `runIf`: ```ts import { inState } from '@forge-game-engine/forge/states'; -world.addSystem(createEnemyAiEcsSystem(time), { - runIf: inState(gameState, 'playing'), -}); -world.addSystem(createMenuInputEcsSystem(gameState), { +world.addSystem(enemyAiSystem, { runIf: inState(gameState, 'playing') }); +world.addSystem(menuInputSystem, { runIf: inState(gameState, 'menu', 'paused'), }); ``` -A system isn't queried or updated while its run condition is false. In a -`paused` state that leaves out the gameplay systems, the components those -systems write keep their values until the systems run again. +A system registered without `runIf` runs on every tick in every state. While +its run condition returns `false`, a system isn't queried and its `update` +isn't called. `addSystemGroup` takes `runIf` in the same way. See +[Run conditions](../ecs/system.md#run-conditions). -`addSystemGroup` takes a `runIf` too, for a whole group of systems. -See [System](../ecs/system.md#run-conditions) for how run conditions work. +## Running a system when a state is entered or left -## Running systems when a state is entered or left - -Work that happens once per transition (spawning the player, saving a high -score, showing a screen) goes in a system registered in the state's -`enterGroup` or `exitGroup`, with `runIf: onEnter(...)` or `runIf: onExit(...)`: +`onEnter(gameState, ...names)` returns a run condition that is `true` only on +the tick one of `names` is entered. `onExit(gameState, ...names)` is `true` +only on the tick one of `names` is left. Register these systems in +`gameState.enterGroup` and `gameState.exitGroup`: ```ts import { onEnter, onExit } from '@forge-game-engine/forge/states'; -world.addSystem(createSpawnPlayerEcsSystem(), { +world.addSystem(spawnPlayerSystem, { group: gameState.enterGroup, runIf: onEnter(gameState, 'playing'), }); -world.addSystem(createSaveHighScoreEcsSystem(scores), { +world.addSystem(saveHighScoreSystem, { group: gameState.exitGroup, runIf: onExit(gameState, 'playing'), }); ``` -A transition runs at the start of a tick, in this order: +On the tick of a transition: -1. The state switches. `gameState.exited` is the state left and - `gameState.entered` the state entered, for this tick only. -2. The `exitGroup` runs. Its systems can still read the entities of the - state being left. -3. [State-scoped entities](./state-scoped-entities.md) whose state ended are +1. `current` changes. `exited` is set to the previous state and `entered` to + the new one. Both are `null` on every other tick. +2. `exitGroup` runs. +3. [State-scoped entities](./state-scoped-entities.md) of the transition are removed. -4. The `enterGroup` runs, so what its systems create exists before any - other system runs. -5. Every other group of the world runs. - -On the first tick, the initial state counts as entered: `entered` is the -initial state and its `onEnter` systems run. Create the entities a state -needs in an `onEnter` system rather than before the first tick, and they're -created the same way every time the state is entered. - -Keep `onEnter` and `onExit` systems in the state's groups. In any other -group, they'd run later in the tick, after gameplay systems that should have -seen what they created. - -### Restarting a state - -Setting the current state again re-enters it: its exit systems run, its -scoped entities are removed, and its enter systems run. `gameState.set('playing')` -while in `playing` restarts it without switching to another state first. - -## Mistakes to avoid - -Checking the state at the top of `update`: - -```ts -// Don't -update: (world, result) => { - if (gameState.current !== 'playing') { - return; - } - // ... -}, -``` +4. `enterGroup` runs. +5. All other groups run. -The system is still queried every tick, and the check is hidden inside it. -Register it with `runIf: inState(gameState, 'playing')` instead. +`exitGroup` and `enterGroup` run before every other group of the world. An +`onEnter` or `onExit` system registered in another group runs on the same +tick, after the systems of every group ordered before it. -Removing a state's entities in an `onExit` system, one query at a time: give -them a [`StateScopedEcsComponent`](./state-scoped-entities.md) instead, so -every entity created for the state is removed, including ones added later. +On the first tick, the initial state is entered: `entered` is the initial +state, `exited` is `null`, and its `onEnter` systems run. diff --git a/documentation-site/docs/docs/states/state-scoped-entities.md b/documentation-site/docs/docs/states/state-scoped-entities.md index 273eb85f8..12e98156c 100644 --- a/documentation-site/docs/docs/states/state-scoped-entities.md +++ b/documentation-site/docs/docs/states/state-scoped-entities.md @@ -4,11 +4,10 @@ sidebar_position: 2 # State-Scoped Entities -An entity that belongs to a state, such as a menu's labels or the enemies -spawned while playing, gets a -[`StateScopedEcsComponent`](/Forge/docs/api/interfaces/StateScopedEcsComponent). -When its state ends, the transition removes it, so no system has to find -and remove each kind of entity a state created. +A +[`StateScopedEcsComponent`](/Forge/docs/api/interfaces/StateScopedEcsComponent) +removes its entity when a [`GameState`](./index.md) leaves or enters given +states. ```ts import { addStateScopedComponent } from '@forge-game-engine/forge/states'; @@ -19,44 +18,30 @@ addStateScopedComponent(world, enemy, { }); ``` -The entity is removed when `gameState` leaves one of `removeOnExit`, or enters -one of `removeOnEnter`. At least one of the two lists has to name a state, -or `addStateScopedComponent` throws. +- `removeOnExit`: the entity is removed on a transition that leaves one of + these states. +- `removeOnEnter`: the entity is removed on a transition that enters one of + these states. -Removal happens between the state's `exitGroup` and `enterGroup`, so -`onExit` systems still see the entities, and `onEnter` systems don't. +Both default to `[]`. `addStateScopedComponent` throws when both are empty. +An entity is only removed by transitions of the `GameState` in its `state` +field. -## Removing on exit or on enter +## When an entity is removed -`removeOnExit` is the common case: the menu's labels go when the menu is -left. +Removal happens on the tick of the transition, after `exitGroup` runs and +before `enterGroup` runs. Systems in `exitGroup` can read the entity; +systems in `enterGroup` can't. -`removeOnEnter` is for entities that stay after their state ends. Enemies -left over from `playing` can stay on screen during `gameOver`, and be removed -when `playing` or `menu` is entered: +Calling `set` with the current state leaves and enters that state, so both +lists are checked against it. -```ts -addStateScopedComponent(world, enemy, { - state: gameState, - removeOnEnter: ['playing', 'menu'], -}); -``` - -Re-entering a state counts as entering it, so `removeOnEnter: ['playing']` -also removes the entity when `gameState.set('playing')` restarts `playing`. - -An entity scoped with `removeOnEnter` on the initial state and created -before the first tick is removed on that tick, since the initial state is -entered then. - -## Entities created at runtime - -Scope an entity where it's created. For particles, which their emitter -creates, add the component in the emitter's `onParticleSpawned` callback. +On the first tick, the initial state is entered. An entity that exists +before the first tick and lists the initial state in `removeOnEnter` is +removed on that tick. ## Children -Removing a scoped entity removes that entity only. Removing an entity -doesn't remove the entities parented to it with `addParentComponent` (see -[World](../ecs/world.md#removing-an-entity-from-the-world)), so give each -child its own `StateScopedEcsComponent` with the same lists. +The entity is removed with `world.removeEntity`, which removes only that +entity. Entities parented to it with `addParentComponent` aren't removed. +Give each child its own `StateScopedEcsComponent`. diff --git a/src/ecs/ecs-world.ts b/src/ecs/ecs-world.ts index 01c086567..fefbe8e26 100644 --- a/src/ecs/ecs-world.ts +++ b/src/ecs/ecs-world.ts @@ -121,13 +121,13 @@ export class EcsWorld implements Updatable, Stoppable { /** * The group that runs before every other group of the tick, however the - * other groups are ordered. Game state transitions run here, so every - * system of a tick sees the same state. Ordering a group `before` it - * throws. + * other groups are ordered. Game state transitions run in it. Ordering a + * group `before` it throws. * - * A group ordered `after` it (or after another group that is) joins the - * start of the tick: it runs before every group that isn't, including - * groups added later. A game state's exit and enter groups work this way. + * A group ordered `after` it, or after another group ordered that way, is + * a start-of-tick group: it runs before every other group, including + * groups registered later. A game state's exit and enter groups are + * start-of-tick groups. */ get firstSystemGroup(): EcsSystemGroup { return this._firstSystemGroup; diff --git a/src/states/game-state.ts b/src/states/game-state.ts index 0fc3eda89..9c0c0aecb 100644 --- a/src/states/game-state.ts +++ b/src/states/game-state.ts @@ -6,11 +6,10 @@ import { } from './systems/state-transition-system.js'; /** - * A named top-level state of a game (loading, menu, playing, paused, game - * over, ...), switched at the start of a tick. Create one with - * {@link createGameState}, run systems only in some states with `inState`, - * and run systems when a state is entered or left with `onEnter`/`onExit` in - * its `enterGroup` and `exitGroup`. + * One value from a fixed set of state names, changed at the start of a + * tick. Create one with {@link createGameState}. `inState` runs a system only + * in some states; `onEnter`/`onExit` systems in `enterGroup`/`exitGroup` run + * on the tick a state is entered or left. * * @typeParam TName - The names of the states. */ @@ -47,10 +46,10 @@ export interface GameState { readonly enterGroup: EcsSystemGroup; /** - * Requests a transition, applied at the start of the next tick. If it's - * called more than once in a tick, the last call wins. Requesting the - * current state re-enters it: its exit and enter systems run again and - * its scoped entities are removed, which restarts it. + * Requests a transition, applied at the start of the next tick. When it's + * called more than once in a tick, the last call is applied. Requesting + * the current state re-enters it: its exit systems, scoped-entity removal + * and enter systems run as for any other transition. * @param next - The state to switch to. */ set(next: TName): void; @@ -64,7 +63,7 @@ export interface GameState { * previous tick; the state's `exitGroup`; a system that removes the * entities whose `StateScopedEcsComponent` matches the transition; and the * state's `enterGroup`. Every other group of the world runs after them, so - * every system sees the same state for the whole tick. + * every system in a tick reads the same `current`. * * On the first tick, `initial` counts as entered, so its `onEnter` systems * run. From 1d32a3e63af5b2157bb1293a27322afd97222335 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:04:29 +0000 Subject: [PATCH 14/15] docs(skills): document-feature writes feature guides, leaving per-member detail to the API reference The skill now separates guides (the user manual) from the generated API reference, requires an outline of headings named after the feature's concepts and tasks, and bans generic headings such as "Worked example", "Gotchas" and "Common mistakes". The game states guide is rewritten to it as a single page; the separate state-scoped entities page is folded in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- .claude/skills/document-feature/SKILL.md | 287 ++++++++++-------- documentation-site/docs/docs/states/index.md | 106 ++++--- .../docs/docs/states/state-scoped-entities.md | 47 --- 3 files changed, 225 insertions(+), 215 deletions(-) delete mode 100644 documentation-site/docs/docs/states/state-scoped-entities.md diff --git a/.claude/skills/document-feature/SKILL.md b/.claude/skills/document-feature/SKILL.md index 633a6320f..255a014d6 100644 --- a/.claude/skills/document-feature/SKILL.md +++ b/.claude/skills/document-feature/SKILL.md @@ -1,154 +1,183 @@ --- name: document-feature -description: Write or update technical documentation in documentation-site/docs/docs for a Forge concept (a component, system, class, module or ECS mechanism). Produces correct, literal, to-the-point reference prose that documents one concept in isolation, with no flowery language, no sales pitch, no game- or demo-specific detail and no asides about other concepts. Also makes sure the public API has JSDoc for the generated API reference. Use whenever writing or editing any page or section under documentation-site/docs/docs, including JSDoc prose that ends up in the API reference. +description: Write or update a feature guide in documentation-site/docs/docs, Forge's user manual. A guide explains one complete feature top-down (what it is, the concepts it's made of, and how to do each basic task with it) in correct, literal, to-the-point prose, with an outline of headings a reader can scan to find a task. Per-member detail (parameters, options, defaults, edge cases) belongs in the generated API reference, so this skill also makes sure the JSDoc covers it. Use whenever writing or editing any page or section under documentation-site/docs/docs. --- -# Write technical documentation - -A guide page under `documentation-site/docs/docs/` explains one concept: what -it is, how to use it, and how it behaves. It sits next to the generated API -reference (`documentation-site/docs/api/`, built by typedoc from JSDoc, never -hand-edited), so it doesn't restate signatures. - -The bar is **correct, to the point, technical**. Every sentence states a fact -about the concept that a reader needs in order to use it. Anything else is -removed. - -## 1. Learn the concept before writing - -- Read the source, the tests (`*.test.ts`) and the JSDoc of the concept you - are documenting. The tests are the specification of its behavior. -- Write down, for yourself, the facts a user needs: what it is, how to create - or register it, its options and their defaults, what happens when an option - is omitted, when it runs or takes effect (order, timing), what it reads and - writes, what throws, and its limits. -- Every claim in the page must be one of these facts, checked against the - code. If you can't point to the code or test that makes a sentence true, - don't write it. - -## 2. Ensure JSDoc exists - -Every public class, function, property and option needs JSDoc with `@param`, -`@returns` and `@throws` as applicable (see AGENTS.md). The same writing rules -below apply to JSDoc. Fixing a reference entry means fixing the JSDoc in -`/src`; after editing `/src`, run the CLAUDE.md verification steps. - -## 3. Writing rules - -### Document the concept in isolation - -- The page is about one concept. Don't explain neighboring concepts (time, - input, worlds, rendering, dependency injection, general programming - practice) or how they interact with this one, unless the concept's own - behavior can't be stated without them. When the reader needs another - concept, link its page in one clause; don't summarize it. -- Don't describe a particular game, demo or genre. Examples use generic, - self-explanatory names. "A round", "the game-over screen", "the star - catcher" are implementation details of someone's game, not of the engine. -- Don't open with, promote or narrate a demo. A demo is linked, if at all, - once, where the text refers to a specific piece of its code. -- Don't compare the concept to other engines, earlier versions or - alternatives. History and rationale belong in `/design` and the changelog. - -### Be literal and correct - -- State behavior as plain fact in the present tense: "`update` isn't called - while the run condition returns `false`." -- Use the operation's real name: "is removed", "is not queried", "is set to - `null`", "runs before". Don't use metaphors or figurative verbs: no - "gated", "stops them where they are", "hands off", "lives in", "sees", - "wakes up", "takes care of", "under the hood". +# Write a feature guide + +Forge's docs have two parts, like Unity's User Manual and Scripting API: + +- **Guides** (`documentation-site/docs/docs/`, handwritten): the manual. A + reader opens a guide first, to understand a **feature** and its basic + usage. +- **API reference** (`documentation-site/docs/api/`, generated by typedoc + from JSDoc, never hand-edited): every class, function, parameter, option + and default. A reader turns to it once they understand the feature and + need a detail for their specific case. + +This skill writes guides. Good examples in this repo: `ecs/world.md`, +`ecs/entity.md` and `input/actions.md`. Not an example to follow: +`events/custom-events.md`, whose outline ("A worked example", "Gotchas", +"Common mistakes", "Performance notes") doesn't list the event types, or +how to raise an event, listen to one, or deregister a listener. + +## 1. Learn the feature + +Read the source, the tests (`*.test.ts`) and the JSDoc. The tests specify +the behavior. List: + +- the concepts the feature is made of (types, objects, components, + systems) and how they relate; +- the tasks a user does with it, in the order they meet them (create it, + configure it, use it, react to it, remove it); +- the behavior a user must understand to use it at all (when things + happen, what runs in which order, what changes what). + +Every statement in the guide has to be true of the code. If you can't +point to the code or test that makes it true, leave it out. + +## 2. Split guide from reference + +Put something in the **guide** if a user needs it to understand the feature +or to do a basic task with it. Leave it to the **reference** if a user only +needs it for a particular case. + +- A raycast guide explains what a raycast is, how to cast one and how to + read what it hit. Whether it returns every hit or only the first, and how + to cap its distance, are options: they belong in the reference. +- A game state guide explains what a game state is, how to change it, and + how to run systems in some states or when a state changes. That + `addStateScopedComponent` defaults both lists to `[]` is reference + detail. + +The exception is a default that breaks the basic task: if the default makes +the obvious usage behave wrong, say so in the guide, in the section of that +task (see `input/actions.md`'s `noReset` caution). + +Everything left to the reference must be in the JSDoc: every public class, +function, property and option gets JSDoc with `@param`, `@returns` and +`@throws` as applicable (see AGENTS.md), written by the same rules as the +guide. After editing `/src`, run the CLAUDE.md verification steps. + +## 3. Write the outline first + +The headings are the guide's table of contents, and a reader uses them to +find what they came for. Write them before any prose, and check that a +reader looking for any concept or task from step 1 finds it by scanning the +headings alone. + +- One heading per concept or task, named after it: "Event types", + "Creating an event", "Raising an event", "Listening for an event", + "Removing a listener". +- No generic headings: "Overview", "Worked example", "Example", "Gotchas", + "Common mistakes", "Performance notes", "Notes", "Tips", "See also". A + heading says what the section is about. +- Order: what the feature is and the kinds or types it has, then the tasks + in the order a user meets them, ending with removal or cleanup. +- A caveat goes inside the section of the task it affects, as a + `:::caution` or `:::note` admonition, not in a section of its own. +- A feature with several large parts gets one page per part (as + `ecs/world.md`, `ecs/entity.md` and `ecs/system.md` do), and the + `index.md` says what the feature is and what the parts are. + +For an event system, the outline is: + +``` +# Events +## Event types +## Creating an event +## Raising an event +## Listening for an event +## Removing a listener +``` + +## 4. Writing rules + +### One feature, in isolation + +- The guide is about its feature. Don't explain other features (time, + input, worlds, rendering) or general programming (dependency injection, + closures). When the reader needs another feature, link its guide in one + clause. +- Use cases can be named as possibilities ("for example, a pause menu or + a game over screen"), but no single scenario is presented as the use case, + and no one game runs through the page. Don't describe a demo's mechanics. +- Don't open with, promote or narrate a demo. Don't compare the feature to + other engines or earlier versions; rationale and history belong in + `/design` and the changelog. + +### Correct and literal + +- State behavior as plain fact in the present tense: "the system isn't + queried while its run condition returns `false`." +- Use the operation's real name: "is removed", "isn't queried", "runs + before", "is set to `null`". No metaphors or figurative verbs: no + "gated", "stops where it is", "hands off", "lives in", "sees", "wakes up", + "takes care of", "under the hood". - Describe things as what they are in the ECS model. Systems are stateless: - they don't pause, resume, stop "where they are" or remember anything. - Components hold data. A state value doesn't need "setting up"; systems run - when it changes. Don't attribute state, location, intent or feelings to - code. + they don't pause, resume or remember. Components hold data. A state value + needs no "setting up"; systems run when it changes. Don't give code + intent, feelings or a location. - No marketing or filler: no "powerful", "seamless", "simply", "just", - "easily", "elegant", "out of the box", "puts it all together", no - rhetorical questions, no exclamations, no "Note that" or "It's worth - mentioning". Don't vouch for quality ("handles this gracefully"). + "easily", "elegant", "out of the box", no rhetorical questions or + exclamations, no "Note that". Don't vouch for quality ("handles this + gracefully"). - No en-dashes or em-dashes. -### Be to the point - -- Open with one or two sentences that define the concept in technical terms: - what it is and what it does. No scene-setting, no list of what the page - will cover. -- One fact per sentence where possible. Cut a sentence if the reader can use - the concept correctly without it. -- State defaults and omissions explicitly: what happens when an option, a - `runIf`, a group or a field is left out. -- State ordering and timing exactly when the concept has any: in which order - things run, on which tick a change takes effect, what is visible to whom - and when. -- State error conditions the caller must handle or avoid. -- Don't add an example for something that isn't specific to the concept - (passing a value to a factory, importing a module, writing a lambda). +### To the point + +- Open the page with one or two sentences that say what the feature is and + what it does. No scene-setting. +- Each section: say what the task is, show the minimal code for it, then + state what happens as a result. Stop there. +- Cut a sentence if the reader can understand the feature and do the basic + task without it, or if it's a detail of one option (step 2). +- Link the API reference for the members a section uses: + `[ForgeEvent](/Forge/docs/api/classes/ForgeEvent)`. ### Examples -- Minimal: only the code needed to show the concept, with real imports from - the published package path (`@forge-game-engine/forge/`, no +- Minimal: only the code for the task the section covers, with imports + from the published package path (`@forge-game-engine/forge/`, no relative paths, no `.js`). -- Names say what the value is: `gameState` and `GameStateName`, not `screen` - and `Screen`; `enemy`, not `star`. -- Show the call, then state its effect in prose. Don't narrate the example - line by line. -- A "don't do this" example is allowed only for a misuse of this concept's - own API that compiles and silently does the wrong thing. Show it, say what - goes wrong, show the fix. - -### What never goes in a page - -- Full signatures, parameter lists, property lists or method-by-method - walkthroughs (link the API reference: - `[RigidBody](/Forge/docs/api/classes/RigidBody)`). -- Implementation narration: internal data structures, caching, how errors - are produced. -- Content about another concept (see "in isolation" above). -- A "Guides in this section" list on an `index.md` (the sidebar lists them). - -## 4. Page structure - -1. `# Title`: the concept's name in Title Case (`# Game States`, - `# Run Conditions`), not a use-case slogan. -2. Definition: one or two sentences. -3. One `##` section per thing the reader does, in the order they do it: - create it, use it, configure it. Each section states the rule, shows a - minimal example, then states the resulting behavior (order, timing, - defaults, errors). -4. Constraints and limits, if any, in their own section. - -Sections that don't apply are left out. A short page is fine. +- Names say what the value is (`gameState`, `GameStateName`, `enemy`), not + a particular game's vocabulary. +- One example per task. Don't build one long example that the whole page + explains. ## 5. Final pass -Read the page sentence by sentence and delete or rewrite each one that fails -any of these: +**Outline:** read only the headings. Every concept and task from step 1 has +one, each names its topic, and none is generic. + +**Sentences:** read the page sentence by sentence and remove or rewrite any +that fails one of these: -- **True?** You've checked it against the code or tests. -- **About this concept?** Not about time, input, worlds, a demo or a game. -- **Literal?** No metaphor, personification or adjective that judges quality. -- **Needed?** A reader can't use the concept correctly without it. -- **Precise?** Names the exact API, value, order or condition. +- **True:** checked against the code or tests. +- **Guide-level:** needed to understand the feature or do a basic task, not + a detail of one option. +- **About this feature:** not about another feature, a demo or a game. +- **Literal:** no metaphor, personification or quality judgment. +- **Precise:** names the exact API, value, order or condition. ## 6. Mechanics ### Location -Pages live at `documentation-site/docs/docs//.md`, where -`` matches the `/src/` folder name. Add a section to an -existing page when the concept is part of one already documented there -(e.g. run conditions in `ecs/system.md`). A new module folder gets a -`_category_.json` and an `index.md`. +Guides live at `documentation-site/docs/docs//.md`, where +`` matches the `/src/` folder. Add a section to an existing +page when the concept is part of a feature already documented there (run +conditions in `ecs/system.md`). A new module folder gets a +`_category_.json` and an `index.md`; the sidebar lists the pages, so +`index.md` has no "Guides in this section" list. ### Page conventions -- Optional frontmatter `sidebar_position: N` orders pages within a folder; - pick a number after the existing siblings. -- Cross-link guide pages with relative markdown links - (`[World](../ecs/world.md)`), and API reference and demo pages with the - `/Forge` base URL (`/Forge/docs/api/...`, `/Forge/demos/...`). +- Optional frontmatter `sidebar_position: N` orders pages in a folder; pick + a number after the existing siblings. +- Link guide pages with relative markdown links (`[World](../ecs/world.md)`), + and API reference and demo pages with the `/Forge` base URL + (`/Forge/docs/api/...`, `/Forge/demos/...`). ### `_category_.json` @@ -178,6 +207,6 @@ Pick a `position` that no sibling `_category_.json` under - `npx prettier --check` and `npm run cspell` on the changed files. - From `documentation-site/`, `npm run build`: it fails on broken links. -- `npm run start` and open the page: it renders and sits in the right place - in the sidebar. +- `npm run start` and open the page: it renders, and its headings in the + page's table of contents list the feature's concepts and tasks. - If `/src` was edited, run the full CLAUDE.md verification suite. diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index c072f8a59..a5cc753cf 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -4,13 +4,17 @@ sidebar_position: 1 # Game States -A [`GameState`](/Forge/docs/api/interfaces/GameState) holds one value from a -fixed set of state names and changes it at the start of a tick. Run -conditions created from a `GameState` decide which systems run in each -state, and which run on the tick a state is entered or left. +A game state is one value from a fixed set of state names, such as `menu`, +`playing` and `paused`, that changes at the start of a tick. Systems can be +registered to run only in some states, or only on the tick a state is +entered or left, and entities can be removed when a state changes. ## Creating a game state +[`createGameState`](/Forge/docs/api/functions/createGameState) creates a +[`GameState`](/Forge/docs/api/interfaces/GameState) for a world, starting in +the given state: + ```ts import { createGameState } from '@forge-game-engine/forge/states'; @@ -19,44 +23,43 @@ type GameStateName = 'menu' | 'playing' | 'paused'; const gameState = createGameState(world, 'menu'); ``` -`createGameState` registers the systems and groups that apply transitions in -`world`. `gameState.current` is the current state. +`gameState.current` is the current state. ## Changing state -`gameState.set(name)` requests a transition. The transition is applied at -the start of the next tick, in `world.firstSystemGroup`, which runs before -every other group, so every system in a tick reads the same `current`. When `set` is called more than once in a -tick, the last call is applied. +`set` requests a transition: + +```ts +gameState.set('playing'); +``` -Calling `set` with the current state re-enters it. The transition runs the -same steps as a transition to another state. +The transition is applied at the start of the next tick, before any other +system runs, so every system in a tick reads the same `current`. Calling +`set` with the current state leaves and re-enters it. -## Running a system in some states +## Running systems in some states -`inState(gameState, ...names)` returns a run condition that is `true` while -`current` is one of `names`. Pass it as `runIf`: +[`inState`](/Forge/docs/api/functions/inState) creates a run condition that +is true while the game state is one of the given states. Pass it as `runIf` +when registering a system: ```ts import { inState } from '@forge-game-engine/forge/states'; world.addSystem(enemyAiSystem, { runIf: inState(gameState, 'playing') }); -world.addSystem(menuInputSystem, { - runIf: inState(gameState, 'menu', 'paused'), -}); ``` -A system registered without `runIf` runs on every tick in every state. While -its run condition returns `false`, a system isn't queried and its `update` -isn't called. `addSystemGroup` takes `runIf` in the same way. See +The system runs only on ticks where `current` is `playing`. A system +registered without `runIf` runs in every state. See [Run conditions](../ecs/system.md#run-conditions). -## Running a system when a state is entered or left +## Running systems when a state is entered or left -`onEnter(gameState, ...names)` returns a run condition that is `true` only on -the tick one of `names` is entered. `onExit(gameState, ...names)` is `true` -only on the tick one of `names` is left. Register these systems in -`gameState.enterGroup` and `gameState.exitGroup`: +[`onEnter`](/Forge/docs/api/functions/onEnter) and +[`onExit`](/Forge/docs/api/functions/onExit) create run conditions that are +true only on the tick the game state enters or leaves one of the given +states. Register these systems in the game state's `enterGroup` and +`exitGroup`: ```ts import { onEnter, onExit } from '@forge-game-engine/forge/states'; @@ -65,25 +68,50 @@ world.addSystem(spawnPlayerSystem, { group: gameState.enterGroup, runIf: onEnter(gameState, 'playing'), }); + world.addSystem(saveHighScoreSystem, { group: gameState.exitGroup, runIf: onExit(gameState, 'playing'), }); ``` -On the tick of a transition: +On the tick of a transition, the world runs: + +1. the transition: `current` changes, `exited` is the state left and + `entered` the state entered; +2. `exitGroup`; +3. the removal of state-scoped entities (see below); +4. `enterGroup`; +5. every other group. + +On the first tick, the initial state is entered, so its `onEnter` systems +run. + +## Removing entities when a state changes + +[`addStateScopedComponent`](/Forge/docs/api/functions/addStateScopedComponent) +marks an entity to be removed when the game state leaves or enters given +states: + +```ts +import { addStateScopedComponent } from '@forge-game-engine/forge/states'; + +addStateScopedComponent(world, enemy, { + state: gameState, + removeOnExit: ['playing'], +}); +``` -1. `current` changes. `exited` is set to the previous state and `entered` to - the new one. Both are `null` on every other tick. -2. `exitGroup` runs. -3. [State-scoped entities](./state-scoped-entities.md) of the transition are - removed. -4. `enterGroup` runs. -5. All other groups run. +This entity is removed when the game state leaves `playing`. +`removeOnEnter` removes an entity when the game state enters one of the +listed states instead. With `removeOnEnter: ['playing', 'menu']`, an entity +created while playing is kept after `playing` is left, and removed when +`playing` or `menu` is next entered. -`exitGroup` and `enterGroup` run before every other group of the world. An -`onEnter` or `onExit` system registered in another group runs on the same -tick, after the systems of every group ordered before it. +Removal happens between `exitGroup` and `enterGroup`, so `onExit` systems +can still read the entity. -On the first tick, the initial state is entered: `entered` is the initial -state, `exited` is `null`, and its `onEnter` systems run. +:::note +Only the entity itself is removed. Entities parented to it aren't removed, +so give each child its own state-scoped component. +::: diff --git a/documentation-site/docs/docs/states/state-scoped-entities.md b/documentation-site/docs/docs/states/state-scoped-entities.md deleted file mode 100644 index 12e98156c..000000000 --- a/documentation-site/docs/docs/states/state-scoped-entities.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -sidebar_position: 2 ---- - -# State-Scoped Entities - -A -[`StateScopedEcsComponent`](/Forge/docs/api/interfaces/StateScopedEcsComponent) -removes its entity when a [`GameState`](./index.md) leaves or enters given -states. - -```ts -import { addStateScopedComponent } from '@forge-game-engine/forge/states'; - -addStateScopedComponent(world, enemy, { - state: gameState, - removeOnExit: ['playing'], -}); -``` - -- `removeOnExit`: the entity is removed on a transition that leaves one of - these states. -- `removeOnEnter`: the entity is removed on a transition that enters one of - these states. - -Both default to `[]`. `addStateScopedComponent` throws when both are empty. -An entity is only removed by transitions of the `GameState` in its `state` -field. - -## When an entity is removed - -Removal happens on the tick of the transition, after `exitGroup` runs and -before `enterGroup` runs. Systems in `exitGroup` can read the entity; -systems in `enterGroup` can't. - -Calling `set` with the current state leaves and enters that state, so both -lists are checked against it. - -On the first tick, the initial state is entered. An entity that exists -before the first tick and lists the initial state in `removeOnEnter` is -removed on that tick. - -## Children - -The entity is removed with `world.removeEntity`, which removes only that -entity. Entities parented to it with `addParentComponent` aren't removed. -Give each child its own `StateScopedEcsComponent`. From 08c571e2c5c1dcaf8522d47ca701b2639bf66776 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 20:11:33 +0000 Subject: [PATCH 15/15] docs(states): leave entity removal behavior to the World guide The game states guide no longer describes what happens to a removed entity's children; that's removal and parenting behavior, documented where those features are. The document-feature skill gains the rule. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BAAGHGoEyhYQhFcyTw37jE --- .claude/skills/document-feature/SKILL.md | 5 +++++ documentation-site/docs/docs/states/index.md | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.claude/skills/document-feature/SKILL.md b/.claude/skills/document-feature/SKILL.md index 255a014d6..b59b6c517 100644 --- a/.claude/skills/document-feature/SKILL.md +++ b/.claude/skills/document-feature/SKILL.md @@ -99,6 +99,11 @@ For an event system, the outline is: input, worlds, rendering) or general programming (dependency injection, closures). When the reader needs another feature, link its guide in one clause. +- Behavior that belongs to another feature is documented once, in that + feature's guide, even when this feature triggers it. A game state removes + entities; what removing an entity does (to its components, its children) + is the World's and parenting's behavior, so the game state guide doesn't + describe it. - Use cases can be named as possibilities ("for example, a pause menu or a game over screen"), but no single scenario is presented as the use case, and no one game runs through the page. Don't describe a demo's mechanics. diff --git a/documentation-site/docs/docs/states/index.md b/documentation-site/docs/docs/states/index.md index a5cc753cf..93ace7f8e 100644 --- a/documentation-site/docs/docs/states/index.md +++ b/documentation-site/docs/docs/states/index.md @@ -110,8 +110,3 @@ created while playing is kept after `playing` is left, and removed when Removal happens between `exitGroup` and `enterGroup`, so `onExit` systems can still read the entity. - -:::note -Only the entity itself is removed. Entities parented to it aren't removed, -so give each child its own state-scoped component. -:::