diff --git a/.claude/skills/document-feature/SKILL.md b/.claude/skills/document-feature/SKILL.md index 21b38a046..b59b6c517 100644 --- a/.claude/skills/document-feature/SKILL.md +++ b/.claude/skills/document-feature/SKILL.md @@ -1,165 +1,192 @@ --- 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 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. --- -# 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 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: -### Page conventions +``` +# Events +## Event types +## Creating an event +## Raising an event +## Listening for an event +## Removing a listener +``` -- 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.: +## 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. +- 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. +- 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 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", no rhetorical questions or + exclamations, no "Note that". Don't vouch for quality ("handles this + gracefully"). +- No en-dashes or em-dashes. + +### 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 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`, `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 + +**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:** 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 + +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. - ```ts - import { RigidBody } from '@forge-game-engine/forge/physics'; - ``` +### Page conventions -- Cross-link related guide pages with relative markdown links, e.g. - `[World docs](./world.md)`. -- Do not use any en-dashes or em-dashes. +- 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` shapes +### `_category_.json` -For a module with an `index.md` overview page: +With an `index.md` overview page: ```json { @@ -169,7 +196,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 +205,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 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/AGENTS.md b/AGENTS.md index 073eb66ba..35b5ca242 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 30599d902..13a4a6d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ 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 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/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 diff --git a/design/game-states.md b/design/game-states.md index 5daf6ac2c..b11471f62 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,14 @@ 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`. Removing an entity that was +already removed is a no-op +([`generational-entity-ids.md`](./generational-entity-ids.md)). Once +[`hierarchy-removal.md`](./hierarchy-removal.md) ships, removal takes the +entity's descendants with it. Until then, it 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 +322,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..b643e099d 100644 --- a/documentation-site/docs/docs/ecs/system.md +++ b/documentation-site/docs/docs/ecs/system.md @@ -86,6 +86,32 @@ const system: EcsSystem<[Sprite]> = { `createRenderEcsSystem` uses this for a sprite's optional rotation, scale, and flip components. +## Run conditions + +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(spawnerSystem, { runIf: () => !settings.isPaused }); +``` + +- 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` 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`. + +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. + +`inState`, `onEnter` and `onExit` create run conditions from a +[game state](../states/index.md). + ## 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 ecf667429..63ffde77c 100644 --- a/documentation-site/docs/docs/ecs/world.md +++ b/documentation-site/docs/docs/ecs/world.md @@ -37,6 +37,7 @@ world.removeEntity(entity); This removes every component/tag the entity had, then raises `onEntityRemoved` with the entity. The world reuses the entity's slot for a later entity, under a new handle, so the removed entity's handle never refers to the new one. +Entities parented to it (with `addParentComponent`) aren't removed with it. Removing an entity that's already been removed does nothing, and `removeEntity` returns `false` instead of `true`. Use `isAlive(entity)` to check @@ -207,6 +208,33 @@ used to serve. group; ordering systems across different groups is done by ordering their groups against each other instead. +### The first group + +`world.firstSystemGroup` runs before every other group on every tick. +Ordering a group `before` it throws. + +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'); + +world.addSystemGroup(loadLevelGroup, { after: [world.firstSystemGroup] }); +``` + +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 + +`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)`. @@ -225,7 +253,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..93ace7f8e --- /dev/null +++ b/documentation-site/docs/docs/states/index.md @@ -0,0 +1,112 @@ +--- +sidebar_position: 1 +--- + +# Game States + +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'; + +type GameStateName = 'menu' | 'playing' | 'paused'; + +const gameState = createGameState(world, 'menu'); +``` + +`gameState.current` is the current state. + +## Changing state + +`set` requests a transition: + +```ts +gameState.set('playing'); +``` + +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 systems in some states + +[`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') }); +``` + +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 systems when a state is entered or left + +[`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'; + +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, 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'], +}); +``` + +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. + +Removal happens between `exitGroup` and `enterGroup`, so `onExit` systems +can still read the entity. 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..dc7560174 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/_create-game.ts @@ -0,0 +1,179 @@ +import { createTransformEcsSystem } from '@forge-game-engine/forge/common'; +import defaultFontImageUrl from '@forge-game-engine/forge/fonts/default/default.png'; +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 { + Color, + createCamera, + createCameraEcsSystem, + getCameraView, + 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. + * @returns The created game. + */ +export const createGameStatesGame = async (): Promise => { + const { game, world, renderContext, time } = createGame('demo-game'); + + const camera = createCamera(world, { + isStatic: true, + verticalWorldUnits: DEMO_VERTICAL_WORLD_UNITS, + }); + + const fontAtlas = await new FontAtlasCache( + renderContext.imageCache, + ).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')), + 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 = getCameraView(world, camera, renderContext).size; + 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..8c1ee02e5 --- /dev/null +++ b/documentation-site/src/pages/demos/game-states/index.tsx @@ -0,0 +1,60 @@ +import React, { JSX } from 'react'; +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 { + 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 ebc37d332..0aa04007c 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 6b82fb02a..1df77d055 100644 --- a/src/ecs/ecs-world.test.ts +++ b/src/ecs/ecs-world.test.ts @@ -666,6 +666,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 c626d231c..fefbe8e26 100644 --- a/src/ecs/ecs-world.ts +++ b/src/ecs/ecs-world.ts @@ -6,6 +6,7 @@ import { ParameterizedForgeEvent } from '../events/parameterized-forge-event.js' import { EcsSystem } from './ecs-system.js'; import { entityGeneration, entityIndex, formatEntity } from './entity.js'; import { createEntityHandle, maxEntities } from './entity-layout.js'; +import { RunCondition } from './run-condition.js'; export interface QueryResult { entities: readonly number[]; @@ -30,6 +31,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 { @@ -44,6 +52,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 { @@ -74,7 +88,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'); @@ -84,8 +106,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 in it. Ordering a + * group `before` it throws. + * + * 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; } /** @@ -105,16 +150,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); @@ -125,6 +197,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); + } } /** @@ -142,6 +220,7 @@ export class EcsWorld implements Updatable, Stoppable { group = this._defaultSystemGroup, before = [], after = [], + runIf, } = options; if (!this._groupGraph.has(group)) { @@ -162,6 +241,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); @@ -185,13 +268,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); + } } } @@ -541,17 +642,73 @@ 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); + } + + 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 (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.`, + ); + } - for (const group of this._groupGraph.topologicalSort()) { - const systemGraph = this._systemGraphsByGroup.get(group); + return; + } + + 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); - if (systemGraph) { - orderedSystems.push(...systemGraph.topologicalSort()); + for (const laterGroup of this._restOfTickGroups) { + this._groupGraph.addEdge(group, laterGroup); } + + return; } - return orderedSystems; + this._restOfTickGroups.add(group); + + for (const earlierGroup of this._startOfTickGroups) { + this._groupGraph.addEdge(earlierGroup, group); + } } } diff --git a/src/ecs/index.ts b/src/ecs/index.ts index b74a6d57a..2b3e0838a 100644 --- a/src/ecs/index.ts +++ b/src/ecs/index.ts @@ -3,3 +3,4 @@ export * from './ecs-system.js'; export * from './ecs-system-group.js'; export * from './ecs-world.js'; export * from './entity.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..bd764bc7c --- /dev/null +++ b/src/states/components/state-scoped-component.ts @@ -0,0 +1,88 @@ +import { createComponentId } from '../../ecs/ecs-component.js'; +import { EcsWorld } from '../../ecs/ecs-world.js'; +import { formatEntity } from '../../ecs/entity.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 ${formatEntity(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..73af65bbd --- /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('runs systems only in the states inState names', () => { + 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..9c0c0aecb --- /dev/null +++ b/src/states/game-state.ts @@ -0,0 +1,129 @@ +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'; + +/** + * 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. + */ +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 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 what + * they create exists before any other system runs. + */ + readonly enterGroup: EcsSystemGroup; + + /** + * 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; +} + +/** + * 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 in a tick reads the same `current`. + * + * 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..ed7ae2015 --- /dev/null +++ b/src/states/run-conditions.ts @@ -0,0 +1,46 @@ +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`. + * 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. + */ +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; + }, + }; +}