diff --git a/.changeset/README.md b/.changeset/README.md index b071013..9b6335c 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -1,16 +1,24 @@ -# Changesets +# Releases -Every change to a published package needs a changeset: run `pnpm changeset`, -pick the bump, and describe the change for the changelog. +Every change to a published package needs a changeset (`pnpm changeset`). All `@typeonce/ritmo*` packages are one fixed group and always share a version. A breaking entry carries its fix (a short diff or the exact replacement): the shipped `CHANGELOG.md` is the upgrade guide. -All `@typeonce/ritmo*` packages are one fixed group: they are always -released together with the same version. +On merge to `main`, the release workflow opens a "Version Packages" pull request. Merging it publishes to npm with trusted publishing and provenance. The workflow runs only while the repository variable `RITMO_RELEASES_ENABLED` is `true`. -On merge to `main`, the release workflow opens a "Version Packages" pull -request that applies pending changesets. Merging that pull request publishes -the new version to npm. +If a publish fails, start a fresh run (`gh workflow run release.yml --ref main`) instead of re-running the job: a re-run fails with `TLOG_CREATE_ENTRY_ERROR (409)` because the first attempt already logged its provenance. -The workflow waits until the repository is named `typeonce-dev/ritmo` and -the repository variable `RITMO_RELEASES_ENABLED` is `true`. -Follow [RELEASING.md](../RELEASING.md) for the first Ritmo publication and -trusted-publisher setup before relying on automatic publishing. +## Add a package + +Trusted publishing cannot create a package, so a new `packages/` (`@typeonce/`) is published once by hand: + +1. In its PR: give it the group's current version, and add it to the `fixed` group in `config.json`, to `scripts/pack-check.ts`, and to `tsconfig.packages.json`, with a changeset. +2. After merge, from clean `main`: + + ```sh + pnpm install --frozen-lockfile && pnpm run check && pnpm pack:check + npm login + mkdir -p /tmp/ritmo-bootstrap && pnpm build && pnpm --dir packages/ pack --pack-destination /tmp/ritmo-bootstrap + npm publish /tmp/ritmo-bootstrap/typeonce--.tgz --access public --provenance=false --ignore-scripts + ``` + +3. On npm, add a trusted publisher: `typeonce-dev` / `ritmo` / `release.yml` / environment `npm`, and tick **Allow npm publish** (without it the workflow gets `403 OIDC permission denied`). Do it close to the next release: an unvalidated publisher expires after two days. +4. After the next CI release publishes it, require 2FA and disallow token publishing in its npm settings. diff --git a/.changeset/agent-docs-and-simulation-coverage.md b/.changeset/agent-docs-and-simulation-coverage.md index f9bc03a..ba1b2c4 100644 --- a/.changeset/agent-docs-and-simulation-coverage.md +++ b/.changeset/agent-docs-and-simulation-coverage.md @@ -12,6 +12,6 @@ - **Breaking: `ritmo-devtools lint` fails on an uncovered simulation module.** Every module the entry loads that the `ritmo/keep-simulation-headless` override in `.oxlintrc.json` does not cover is reported as `uncovered-simulation-module`, and the command exits 1. Add the module (or its folder) to the simulation override's `"files"`, or stop importing values from it. Modules loaded only inside `lintTarget` are not checked. Requires Node 22.15 or newer (`engines.node` is now `>=22.15`). - **An agent skill ships with `@typeonce/ritmo`** in `skills/ritmo/SKILL.md`, for every change: where the docs are, and principles for where a game's code and state belong. Link it into the folder the game's agent reads skills from, for example `ln -s ../../node_modules/@typeonce/ritmo/skills/ritmo .agents/skills/ritmo`. `SETUP.md` in `@typeonce/ritmo` is the one-time setup: the base packages and checks every game uses, the add-ons, and linking the skill. -- **Every package ships its `CHANGELOG.md`.** The upgrade notes for 0.8.0 and 0.9.0 moved from the repository's `MIGRATION.md` into the `@typeonce/ritmo` changelog. +- **Every package ships its `CHANGELOG.md`.** The upgrade notes for 0.8.0 and 0.9.0 are in the `@typeonce/ritmo` changelog. - **JSDoc corrections.** Examples no longer provide randomness or time as services (use the `Random` resource and `Clock`), the `Command` module example declares its commands, inspector docs state that `added`/`changed` reads report records since the inspector's previous evaluation, and every example uses prefixed names (`"Game/Position"`). Guidance that lived only in the repository's guides (spawn order, `ReadonlyValue` helpers, re-entering a state, `thisTick` and plugin systems, service effects and rollback) is now in the JSDoc. - Package READMEs link other packages' READMEs by GitHub URL, so the links work on npm. diff --git a/.changeset/skill-playbooks-and-renderer-free-schema.md b/.changeset/skill-playbooks-and-renderer-free-schema.md new file mode 100644 index 0000000..e1a5fe6 --- /dev/null +++ b/.changeset/skill-playbooks-and-renderer-free-schema.md @@ -0,0 +1,20 @@ +--- +"@typeonce/ritmo": patch +"@typeonce/ritmo-math": patch +"@typeonce/oxlint-plugin-ritmo": minor +--- + +**The Ritmo skill routes each change to a playbook, and simulation code names no renderer type.** + +- **Breaking: `ritmo/keep-simulation-headless` reports type-only renderer imports** (`import type { Container } from "pixi.js"`, `export type ... from "three"`) and subpath imports of `@typeonce/ritmo-render` and `@typeonce/ritmo-pixi`. A schema that names a renderer type ties the simulation to that renderer. Move the renderer service (a `Descriptor.Service>()(...)`) and the systems that use it into render code; services are not registered in the schema, so nothing else changes: + + ```diff + -// src/game/schema.ts + -import type { Sprite } from "pixi.js" + -export const Sprites = Descriptor.Service>()("Game/Sprites") + +// src/render/sprites.ts + +import type { Sprite } from "pixi.js" + +export const Sprites = Descriptor.Service>()("Game/Sprites") + ``` + +- **The skill in `skills/ritmo` now has playbooks**: one per kind of change (a mechanic, content, UI and rendering, a bug fix, a refactor, state and upgrades), each a short list of steps that ends with the check that proves it. `SKILL.md` routes to them and keeps the principles. A game that already links the skill folder gets them on upgrade. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a068055..ef7a88d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,8 +17,7 @@ permissions: jobs: release: name: Version or publish - # Enable after the organization transfer, rename, first publication, and npm trust setup. - # See RELEASING.md. An unset variable leaves release automation paused. + # An unset RITMO_RELEASES_ENABLED variable pauses release automation (see .changeset/README.md). if: github.repository == 'typeonce-dev/ritmo' && vars.RITMO_RELEASES_ENABLED == 'true' runs-on: ubuntu-latest environment: npm diff --git a/.oxlintrc.json b/.oxlintrc.json index 0e3ebf4..2c24c9f 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -10,7 +10,7 @@ { "files": [ "packages/*/src/**", - "examples/**", + "integration/**", "scripts/**" ], "rules": { @@ -22,11 +22,7 @@ { "files": [ "packages/*/src/**", - "examples/*/simulation.ts", - "examples/*/schema.ts", - "examples/*/systems/**", - "examples/genetics/logic.ts", - "examples/genetics/math.ts" + "integration/*/**" ], "rules": { "ritmo/use-clock-for-time": "error", @@ -35,22 +31,12 @@ "ritmo/keep-simulation-headless": "error", "ritmo/no-infinity-in-simulation-data": "error" } - }, - { - "files": [ - "examples/*/systems/render*.ts" - ], - "rules": { - "ritmo/keep-simulation-headless": "off" - } } ], "ignorePatterns": [ "**/dist/**", "**/node_modules/**", "coverage/**", - ".agents/**", - "docs/**", - "output/**" + ".agents/**" ] } diff --git a/AGENTS.md b/AGENTS.md index 64bcdb7..e331340 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,112 +1,55 @@ ## Vision -- This library is agent-first: coding agents write all code that uses it; a human directs the game and does not read the code. -- The library is the agent's guardrail. Guarantees come in three layers: (1) compile time, a wrong call does not compile and the error names the fix; (2) runtime inspection via headless devtools sessions (lints, invariants, why/journal); (3) lock-in, every found bug becomes a test. -- One way to do each thing. Two APIs for one job is a safety bug: an agent will pick both. -- Names and JSDoc are part of the API: each export says what it is for, when to use it, and which neighbouring API to use instead. -- The library is early: breaking changes, removals, and renames are welcome. Do NOT keep deprecated aliases or re-exports. Long-term clean API first. - -## Repo Structure -- [`packages/core`](./packages/core/): strict public ECS/runtime library surface, plus the platform-free fixed-step loop (`FixedLoop`). -- [`packages/browser`](./packages/browser/): browser host adapters (BrowserTickSource, BrowserHostClock) and keyboard, pointer, and touch input. -- [`packages/render`](./packages/render/): renderer-agnostic node registry and render-sync systems. -- [`packages/pixi`](./packages/pixi/): Pixi container adapter (`PixiNodeRegistry.make`) for the render package's registry contract. -- [`packages/math`](./packages/math/): validated math values (Scalar, Vector2, Size2, Aabb, InputAxis) and hot-path game helpers (Vec2, Box2, Collide2, SpatialGrid, FlowField, Random, Ops). -- [`packages/devtools`](./packages/devtools/): debug sessions, trace history, and text reports over the core `Debug` handle. -- [`packages/oxlint-plugin`](./packages/oxlint-plugin/): oxlint rules (`ritmo/*`) for game code: deterministic, headless simulation files and checked runtime results. -- [`examples`](./examples/): workspace example apps that consume packages by package name. - -## Package Scope -- Packages never draw. Drawing, particles, and effects stay in games. -- Bridge code that hands a renderer or the platform what it needs (nodes, positions, input, timing) and glue that would otherwise repeat in every game belong in the support packages. -- Hand the game the data it needs to draw (like `Touch`'s `view()`), never the drawing itself. -- A package earns its place when the host platform or an integration boundary defines what it owns, the repeated logic is infrastructure (not gameplay), and games vary it through typed inputs, descriptors, and callbacks without casts or explicit generics. Feature bundles (a platformer HUD, a browser runtime that wires input, rendering, and state together) stay in games. -- A plugin is a function `(Game, options) => System | Schedule` that returns ordinary systems the game places in its own schedules, like `RenderSync.system(Game, ...)` and `InputCapture.system(Game, ...)`. No plugin objects, registries, or composition steps; a plugin's descriptors are declared by the game or exported as a `Schema.fragment(...)`. -- Generic contracts have generic names (`NodeRegistry`); adapters put the implementation in the name (`PixiNodeRegistry`) and export only themselves. The host provides an adapter with `Game.Runtime.service`; systems declare the generic service. -- Core and generic package modules never call a platform API (`performance`, `window`, `document`, timers, file system, network). A platform detail is a generic contract in the owning package with platform implementations injected (like `NodeRegistry` / `PixiNodeRegistry`, `HostClock` / `BrowserHostClock`, `FixedLoop.TickSource` / `BrowserTickSource`). A default may use only what JavaScript itself guarantees in every engine. The compiler enforces this: package configs get only the ES2022 library, so a DOM or Node global does not compile; a host package that needs DOM types lists `"DOM"` in its own `lib`, as `packages/devtools` does for `Monitor`. -- Simulation code reads time from `Clock` and randomness from `Random`; host time (`HostClock`) is for hosts, tools, and debug timing, never for systems. -- Engines may approximate `Math.sin`, `cos`, `atan2`, `exp`, `log`, `pow`, `hypot`, the other transcendental functions, and `a ** b`, and they differ across browsers and CPUs. Package code and simulation code use `Ops` from `@typeonce/ritmo-math` instead (or `Math.sqrt(x * x + y * y)`). -- `pnpm lint` runs the repo's own oxlint plugin (`packages/oxlint-plugin`) over package sources, examples, and scripts (see `.oxlintrc.json`); the simulation rules cover every package source and the examples' simulation files. A rule that should not apply takes `// oxlint-disable-next-line ritmo/ -- `. -- A new pattern that games must follow or avoid, and that types and devtools cannot catch, becomes a rule in `packages/oxlint-plugin`, with tests from real code. Its message says what goes wrong and the exact API to use instead. +- Agent-first: coding agents write all code that uses Ritmo; a human directs the game and does not read the code. +- The library is the agent's guardrail, in three layers: a wrong call does not compile and the error names the fix; headless devtools sessions expose lints, invariants, and why a value changed; every found bug becomes a test. +- One way to do each thing. Two APIs for one job is a safety bug: an agent will use both. +- Names and JSDoc are API: each export says what it is for, when to use it, and which neighbouring API to use instead. +- Early library: break, remove, and rename freely. No deprecated aliases or re-exports. + +## Repo +- `packages/*`: the published packages (see each `package.json` description). Each has `src`, `test`, `dtslint`, and core, math, and render have `bench`. +- `integration/*`: headless game simulations with tests, using the packages by name. +- `.changeset/README.md`: releases and adding a package. + +## Verify +- Every change: `pnpm run check`. +- Changes to what a package publishes (exports, `package.json`, build config): `pnpm pack:check`. +- Every change to a published package: a changeset (`pnpm changeset`). A breaking entry carries its fix. +- Changes to runtime internals or public types: `pnpm bench:check`; record an intended shift with `pnpm bench:update`. For runtime work, compare with your own machine: `pnpm bench --json .bench/before.json`, then `pnpm bench --compare .bench/before.json` (`--filter ` for one case). Under about 20% is noise; do not report it. Add a bench case for a new per-frame hot path. + +## API rules +- Type safety comes first. If a runtime failure can be made impossible by types, it must be; when a guarantee depends on runtime state, the type says it can fail. +- No user-facing casts, no explicit generic arguments for normal use, no splitting schedules or features only to satisfy the compiler. +- Expected failure is a typed `Result`, not an exception. No broad overloads or ambiguous semantics. +- Small composable pieces, each type-safe on its own. +- Validate exact structure at the constructor boundary, derive a normalized carried type once, and reuse it. Optimize internal types only when the public API and its guarantees stay the same. +- A new system access category gets a runtime provision only if the runtime must validate it (one nominal requirement token); otherwise it stays local to the system spec. +- Runtime semantics stay explicit: handles are storage-safe, not proof of liveness; lookups that depend on runtime state are fallible; commands and state transitions apply only at `applyDeferred()` / `applyStateTransitions(...)`; reads (changes, events, transitions, relation failures) are per-reader streams that show each system what was published since its own previous run, once. + +## Type tests +- TSTyche in `packages/*/dtslint/*.tst.ts`. Every public API gets an inferred-type test (`expect(...).type.toBe<...>()`) and a failing test per constraint. +- A failing test names its error: `// @ts-expect-error ` or `expect(fn).type.not.toBeCallableWith(...)` next to a passing call. Pick fragments that name the cause, not printed unions. + +## Package scope +- Packages never draw. They hand games the data to draw (like `Touch`'s `view()`) and own glue every game would repeat: bridges to renderers and platforms (nodes, positions, input, timing). +- A package owns an integration boundary, holds infrastructure (not gameplay), and is varied through typed inputs and callbacks. Feature bundles stay in games. +- A plugin is `(Game, options) => System | Schedule`: no plugin objects, registries, or composition steps. +- Generic contracts have generic names (`NodeRegistry`); adapters name their implementation (`PixiNodeRegistry`) and export only it. The host provides the adapter as a service. +- Core and generic modules call no platform API (`performance`, `window`, timers, file system, network): a platform detail is a contract with injected implementations (`HostClock` / `BrowserHostClock`). Package configs get only the ES2022 lib; a host package lists `"DOM"` itself, as devtools does. +- Package and simulation code read time from `Clock`, randomness from `Random`, and transcendental math from `Ops`; `pnpm lint` enforces this with the repo's own plugin. Disable a rule only with `// oxlint-disable-next-line ritmo/ -- `. +- A pattern games must follow that types and devtools cannot catch becomes a rule in `packages/oxlint-plugin`, tested with real code, whose message says what goes wrong and the API to use instead. ## Docs -- A game's agent sees only what npm ships: each package's `src` JSDoc, `README.md`, `CHANGELOG.md`, `SETUP.md` in core, and the skill in [`packages/core/skills/ritmo`](./packages/core/skills/ritmo/SKILL.md). Guidance a game needs goes there, never in a repo-only file. -- The root `README.md` is the only doc for humans. `AGENTS.md`, `ARCHITECTURE.md`, and `RELEASING.md` are for work on this repo. -- Package READMEs link other packages' docs by their GitHub URL (`https://github.com/typeonce-dev/ritmo/blob/main/packages//README.md`), never by a relative path or into `examples/`, which do not ship. -- A changeset for a breaking change carries its fix (a short diff or the exact replacement), so the shipped `CHANGELOG.md` is the upgrade guide. -- [`packages/core/SETUP.md`](./packages/core/SETUP.md) is the one-time setup (base packages, checks, add-ons, linking the skill); the root README's starter prompt links it. The skill is for every change: where the docs are, and principles that change where code and state go. Anything a JSDoc, README, or lint message already says stays out of both. - -## Core Principles -- Type safety is the first and absolute priority. -- Public APIs must be as strict as possible. -- If a runtime failure can be made impossible by types, it must be. -- If a guarantee depends on runtime state, that uncertainty must be explicit in the type surface. -- Prefer explicit APIs over overloaded, implicit, heuristic, or convenience-first APIs. -- Build APIs as small composable lego blocks that remain independently type-safe. - -All changes to be complete MUST BE VERIFIED by running `pnpm run check`. -Changes to what a package publishes (exports, `package.json`, build config) must also pass `pnpm pack:check`. -Every change to a published package needs a changeset (`pnpm changeset`); all `@typeonce/ritmo*` packages share one version. -Changes to runtime internals or public types must also pass `pnpm bench:check`; if a change intentionally moves the numbers, record it with `pnpm bench:update` and commit the new baseline. - -## Performance -- Runtime cases live in `packages//bench/*.bench.ts` (core, math, render) and use only the public API; add a case when a change creates a new per-frame hot path. -- The committed baseline (`packages/core/bench/baseline.json`) is the target for type-checker metrics on any machine; its runtime scores only compare on similar hardware. -- For runtime work, compare against your own machine: run `pnpm bench --json .bench/before.json` before the change, then `pnpm bench --compare .bench/before.json` after it (exits 1 on a regression over the tolerance). Use `--filter ` to iterate on one case. -- Differences under about 20% are within noise; do not report them as wins or losses. - -## Type Tests -- Public types are tested with TSTyche in `packages/*/dtslint/*.tst.ts` (`pnpm test-types`). -- Every public API gets a test of its inferred type (`expect(...).type.toBe<...>()`) and a failing test for each constraint. -- A failing test states the error it expects: `// @ts-expect-error `, or `expect(fn).type.not.toBeCallableWith(...)` paired with a passing call. `// @ts-expect-error!` (message not checked) is rejected by `pnpm test-types`, because such a test also passes for unrelated errors. -- Pick fragments that name the cause (the missing requirement, the conflicting name, the wrong value) and avoid printed unions, whose order is not stable. - -## Public API Rules -- Do not require user-facing casts. -- Do not require explicit generic arguments for normal usage. -- Do not require users to split schedules, features, or flows into pieces only to satisfy the compiler. -- Do not hide normal failure behind exceptions. -- Do not make the wrong call easy through broad overloads or ambiguous semantics. - -## Internal Type Rules -- Validate exact structure at the constructor boundary. -- Derive normalized carried types once, then reuse them. -- Relax only internal post-validation precision when that precision is not user-meaningful. -- Never trade away root safety, requirement safety, or explicit runtime-failure semantics for compiler performance. -- Internal type optimization is allowed only when the user-facing API stays unchanged and requires no casts or scaffolding. - -```ts -const A = Game.System("A", { queries: { moving: Moving } }, ...) -const B = Game.System("B", { resources: { score: Game.System.writeResource(Score) } }, ...) - -const schedule = Game.Schedule(A, Game.Schedule.applyDeferred(), B) - -// Acceptable internal strategy: -// 1. Validate exact references here. -// 2. Carry a cheaper normalized schedule type afterward. -// 3. Keep the same public guarantees. -``` - -## Explicit Runtime Semantics -- Long-lived references are storage-safe handles, not proof of liveness. -- Lookups and dynamic reads must stay explicit and typed as fallible when they depend on current runtime state. -- Schedule boundaries stay explicit: deferred commands and state transitions are applied only by explicit schedule markers (`applyDeferred()`, `applyStateTransitions(...)`). -- Reads are per-reader streams, not marker-driven: change detection, events, transition events, and relation failures show each system what was published since its own previous run, once. Change detection compares each write's tick with each reader's previous run, so a reader skipped for any number of ticks still sees it; events, transition events, and relation failures are kept until every reading system has run (capped, with `lagged()` reporting loss). - -```ts -const target = lookup.getHandle(handle, query) -if (!target.ok) { - return -} -``` - -## Debugging Game Behavior -- Reproduce headless before changing code: build the game's simulation schedules on a runtime made with `debug: { hostClock: HostClock.test() }` and scripted input (`Keyboard.scripted`). [`examples/top-down/simulation.ts`](./examples/top-down/simulation.ts) is the reference; copy [`examples/top-down/debug.ts`](./examples/top-down/debug.ts) and run it with `node --import tsx - - diff --git a/examples/genetics/logic.ts b/examples/genetics/logic.ts deleted file mode 100644 index b6b17a4..0000000 --- a/examples/genetics/logic.ts +++ /dev/null @@ -1,416 +0,0 @@ -import { Ops } from "@typeonce/ritmo-math" -import { Entity } from "@typeonce/ritmo" -import { FOOD_ENERGY } from "./constants.ts" -import { clamp, lerp, normalizeXYOrZero } from "./math.ts" -import { Agent, Game, Genes, Position, Renderable, Root, Velocity, Vitals, Behavior, Intent, Food, schema } from "./schema.ts" -import type { AgentSnapshot, ArenaValue, FounderSeed, GenesValue, PopulationStatsValue, RenderKind, ShapeKind, SummaryValue } from "./types.ts" - -export const wrap = (value: number, size: number): number => { - if (value < 0) { - return value + size - } - if (value >= size) { - return value - size - } - return value -} - -export const formatGene = (value: number): string => value.toFixed(2) - -export const foodMargin = (arena: ArenaValue): number => - clamp(Math.min(arena.width, arena.height) * 0.08, 48, 84) - -export const founderMargin = (arena: ArenaValue): number => - clamp(Math.min(arena.width, arena.height) * 0.05, 28, 42) - -export const offspringMargin = (arena: ArenaValue): number => - clamp(Math.min(arena.width, arena.height) * 0.03, 20, 32) - -export const hueToHex = (hue: number, saturation: number, lightness: number): number => { - const h = ((hue % 360) + 360) % 360 / 360 - const s = clamp(saturation, 0, 1) - const l = clamp(lightness, 0, 1) - - if (s === 0) { - const channel = Math.round(l * 255) - return (channel << 16) + (channel << 8) + channel - } - - const q = l < 0.5 ? l * (1 + s) : l + s - l * s - const p = 2 * l - q - const hueToChannel = (t: number) => { - let value = t - if (value < 0) { - value += 1 - } - if (value > 1) { - value -= 1 - } - if (value < 1 / 6) { - return p + (q - p) * 6 * value - } - if (value < 1 / 2) { - return q - } - if (value < 2 / 3) { - return p + (q - p) * (2 / 3 - value) * 6 - } - return p - } - - const red = Math.round(hueToChannel(h + 1 / 3) * 255) - const green = Math.round(hueToChannel(h) * 255) - const blue = Math.round(hueToChannel(h - 1 / 3) * 255) - return (red << 16) + (green << 8) + blue -} - -export const makePopulationStats = (births: number): PopulationStatsValue => ({ - births, - deaths: 0, - peak: births -}) - -export const makeRunningSummary = (): SummaryValue => ({ - mode: "running", - title: "Selection pressure is live.", - subtitle: "Food is scarce, hostile phenotypes chase weakness, and lingering in crowds raises risk.", - champion: "Champions are decided at the end of the season.", - dominantLineage: "Dominant lineage: evaluating" -}) - -export const mulberry32 = (seed: number): number => { - let next = seed + 0x6d2b79f5 - next = Math.imul(next ^ (next >>> 15), next | 1) - next ^= next + Math.imul(next ^ (next >>> 7), next | 61) - return (next ^ (next >>> 14)) >>> 0 -} - -export const randomUnit = (seed: number): { seed: number; value: number } => { - const nextSeed = mulberry32(seed) - return { - seed: nextSeed, - value: nextSeed / 0xffffffff - } -} - -export const randomBetween = (seed: number, min: number, max: number): { seed: number; value: number } => { - const next = randomUnit(seed) - return { - seed: next.seed, - value: lerp(min, max, next.value) - } -} - -export const normalizeVector = normalizeXYOrZero - -export const geneDistance = (left: GenesValue, right: GenesValue): number => - ( - Math.abs(left.speed - right.speed) + - Math.abs(left.resilience - right.resilience) + - Math.abs(left.attack - right.attack) + - Math.abs(left.longevity - right.longevity) + - Math.abs(left.sight - right.sight) + - Math.abs(left.fertility - right.fertility) + - Math.abs(left.aggression - right.aggression) - ) / 7 - -export const deriveShape = (genes: GenesValue): ShapeKind => { - if (genes.aggression >= 0.84 || (genes.attack >= 0.82 && genes.aggression >= 0.68)) { - return "spike" - } - if (genes.aggression >= 0.58 || (genes.attack >= 0.7 && genes.aggression >= 0.44)) { - return "triangle" - } - if (genes.resilience >= 0.72) { - return "square" - } - if (genes.fertility >= 0.74 && genes.aggression < 0.4) { - return "diamond" - } - return "circle" -} - -export const deriveAgentStats = ( - lineageId: number, - speciesHue: number, - genes: GenesValue, - generationBorn: number -) => ({ - lineageId, - speciesHue, - shape: deriveShape(genes), - size: lerp(7, 15, genes.resilience * 0.55 + genes.attack * 0.45), - maxHealth: lerp(52, 112, genes.resilience), - maxEnergy: lerp(40, 82, genes.longevity * 0.45 + genes.speed * 0.15 + genes.fertility * 0.4), - moveCost: lerp(4.8, 11.6, genes.speed * 0.65 + genes.attack * 0.2 + (1 - genes.resilience) * 0.15), - attackPower: lerp(14, 38, genes.attack), - attackRange: lerp(18, 36, genes.attack), - sightRange: lerp(68, 170, genes.sight), - mateCompatibility: lerp(0.08, 0.2, genes.fertility * 0.6 + (1 - genes.aggression) * 0.4), - hostilityThreshold: lerp(0.18, 0.42, genes.aggression * 0.78 + genes.attack * 0.22), - fertilityCooldown: lerp(9.6, 4.8, genes.fertility), - longevityLimit: lerp(12, 40, genes.longevity), - generationBorn -}) - -export const deriveRenderable = (kind: RenderKind, genes?: GenesValue, hue?: number) => { - if (kind === "food") { - return { - kind, - shape: "circle" as const, - color: 0x7ddc82, - accent: 0xd6ffd0, - alpha: 0.96 - } - } - - if (kind === "hazard") { - return { - kind, - shape: "circle" as const, - color: 0xbc4b51, - accent: 0xffb4a2, - alpha: 0.26 - } - } - - const safeGenes = genes ?? { - speed: 0.5, - resilience: 0.5, - attack: 0.5, - longevity: 0.5, - sight: 0.5, - fertility: 0.5, - aggression: 0.5 - } - const safeHue = hue ?? 180 - return { - kind, - shape: deriveShape(safeGenes), - color: hueToHex(safeHue, lerp(0.55, 0.84, safeGenes.sight), lerp(0.42, 0.58, safeGenes.longevity)), - accent: hueToHex(safeHue + 18, lerp(0.35, 0.8, safeGenes.resilience), lerp(0.7, 0.86, safeGenes.fertility)), - alpha: lerp(0.75, 0.96, safeGenes.resilience) - } -} - -export const makeInitialGenes = (seed: number, base: GenesValue): { seed: number; genes: GenesValue } => { - let nextSeed = seed - const mutate = (value: number, spread: number) => { - const next = randomBetween(nextSeed, -spread, spread) - nextSeed = next.seed - return clamp(value + next.value, 0.04, 0.98) - } - - return { - seed: nextSeed, - genes: { - speed: mutate(base.speed, 0.16), - resilience: mutate(base.resilience, 0.16), - attack: mutate(base.attack, 0.16), - longevity: mutate(base.longevity, 0.16), - sight: mutate(base.sight, 0.16), - fertility: mutate(base.fertility, 0.16), - aggression: mutate(base.aggression, 0.16) - } - } -} - -const archetypes: ReadonlyArray<{ hue: number; genes: GenesValue }> = [ - { - hue: 28, - genes: { speed: 0.78, resilience: 0.32, attack: 0.86, longevity: 0.34, sight: 0.66, fertility: 0.22, aggression: 0.96 } - }, - { - hue: 120, - genes: { speed: 0.48, resilience: 0.78, attack: 0.42, longevity: 0.76, sight: 0.58, fertility: 0.52, aggression: 0.38 } - }, - { - hue: 205, - genes: { speed: 0.82, resilience: 0.42, attack: 0.48, longevity: 0.48, sight: 0.84, fertility: 0.58, aggression: 0.42 } - }, - { - hue: 312, - genes: { speed: 0.44, resilience: 0.62, attack: 0.58, longevity: 0.66, sight: 0.46, fertility: 0.82, aggression: 0.46 } - }, - { - hue: 356, - genes: { speed: 0.68, resilience: 0.28, attack: 0.94, longevity: 0.24, sight: 0.74, fertility: 0.16, aggression: 0.98 } - } -] as const - -export const makeFounderPool = (seed: number, count: number): { seed: number; founders: ReadonlyArray } => { - let nextSeed = seed - const founders: FounderSeed[] = [] - - for (let index = 0; index < count; index += 1) { - const template = archetypes[index % archetypes.length] - if (!template) { - continue - } - const initial = makeInitialGenes(nextSeed, template.genes) - nextSeed = initial.seed - founders.push({ - lineageId: index % archetypes.length, - speciesHue: template.hue, - genes: initial.genes - }) - } - - return { - seed: nextSeed, - founders - } -} - -export const sampleFoundersFromSurvivors = ( - seed: number, - survivors: ReadonlyArray, - count: number -): { seed: number; founders: ReadonlyArray } => { - if (survivors.length === 0) { - return makeFounderPool(seed, count) - } - - let nextSeed = seed - const weights = survivors.map((survivor) => - Math.max( - 1, - survivor.vitals.health * 0.45 + - survivor.vitals.energy * 0.35 + - survivor.vitals.children * 18 + - survivor.agent.maxHealth * 0.1 - ) - ) - const totalWeight = weights.reduce((total, value) => total + value, 0) - - const chooseParent = (): AgentSnapshot => { - const draw = randomBetween(nextSeed, 0, totalWeight) - nextSeed = draw.seed - let cursor = 0 - const first = survivors[0] - if (!first) { - throw new Error("Expected at least one survivor when sampling founders") - } - for (let index = 0; index < survivors.length; index += 1) { - cursor += weights[index] ?? 0 - if (draw.value <= cursor) { - return survivors[index] ?? first - } - } - return first - } - - const founders: FounderSeed[] = [] - for (let index = 0; index < count; index += 1) { - const primary = chooseParent() - const secondaryCandidate = chooseParent() - const distance = geneDistance(primary.genes, secondaryCandidate.genes) - const compatible = distance <= Math.min(primary.agent.mateCompatibility, secondaryCandidate.agent.mateCompatibility) - const secondary = compatible ? secondaryCandidate : primary - - const mixGene = (left: number, right: number) => { - const blend = randomBetween(nextSeed, 0.35, 0.65) - nextSeed = blend.seed - const mutation = randomBetween(nextSeed, -0.08, 0.08) - nextSeed = mutation.seed - return clamp(lerp(left, right, blend.value) + mutation.value, 0.04, 0.98) - } - - const genes: GenesValue = { - speed: mixGene(primary.genes.speed, secondary.genes.speed), - resilience: mixGene(primary.genes.resilience, secondary.genes.resilience), - attack: mixGene(primary.genes.attack, secondary.genes.attack), - longevity: mixGene(primary.genes.longevity, secondary.genes.longevity), - sight: mixGene(primary.genes.sight, secondary.genes.sight), - fertility: mixGene(primary.genes.fertility, secondary.genes.fertility), - aggression: mixGene(primary.genes.aggression, secondary.genes.aggression) - } - - const lineagePick = randomUnit(nextSeed) - nextSeed = lineagePick.seed - const lineageId = lineagePick.value > 0.5 ? primary.agent.lineageId : secondary.agent.lineageId - const hue = lerp(primary.agent.speciesHue, secondary.agent.speciesHue, 0.5) - - founders.push({ - lineageId, - speciesHue: hue, - genes - }) - } - - return { - seed: nextSeed, - founders - } -} - -export const spawnFoodDraft = (position: { x: number; y: number }) => - Game.Command.spawn( - [Position, position], - [Velocity, { x: 0, y: 0 }], - [Food, { nutrition: FOOD_ENERGY }], - [Renderable, deriveRenderable("food")] - ) - -export const spawnAgentDraft = ( - founder: FounderSeed, - position: { x: number; y: number }, - generationIndex: number, - wanderAngle: number -) => { - const agent = deriveAgentStats(founder.lineageId, founder.speciesHue, founder.genes, generationIndex) - return Game.Command.spawn( - [Position, position], - [Velocity, { x: 0, y: 0 }], - [Genes, founder.genes], - [Agent, agent], - [Vitals, { - health: agent.maxHealth, - energy: agent.maxEnergy * 0.82, - fullness: 0.78, - age: 0, - mateCooldown: lerp(0.2, 2.5, founder.genes.fertility), - matePartner: null, - attackCooldown: lerp(0.1, 0.7, founder.genes.attack), - bondProgress: 0, - children: 0, - pulse: 1 - }], - [Behavior, { - wanderAngle, - wanderTimer: 0, - intentMemoryX: Ops.cos(wanderAngle), - intentMemoryY: Ops.sin(wanderAngle) - }], - [Intent, { - kind: "wander", - x: 0, - y: 0 - }], - [Renderable, deriveRenderable("agent", founder.genes, founder.speciesHue)] - ) -} - -// The summary pass operates on detached snapshots so pairwise decisions stay stable -// even while later lookups re-enter the live world and can fail. -export const collectAgentSnapshots = ( - matches: ReadonlyArray<{ - entity: { - id: Entity.EntityId - } - data: { - position: { get(): { x: number; y: number } } - genes: { get(): GenesValue } - agent: { get(): AgentSnapshot["agent"] } - vitals: { get(): AgentSnapshot["vitals"] } - } - }> -): ReadonlyArray => - matches.map((match) => ({ - handle: Game.Entity.handle(match.entity.id, Agent), - entityId: match.entity.id, - position: match.data.position.get(), - genes: match.data.genes.get(), - agent: match.data.agent.get(), - vitals: match.data.vitals.get() - })) diff --git a/examples/genetics/main.ts b/examples/genetics/main.ts deleted file mode 100644 index 94886fb..0000000 --- a/examples/genetics/main.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { MAX_DELTA_SECONDS } from "./constants.ts" -import { createGeneticsBrowserHost } from "./host.ts" -import { createGeneticsRuntime } from "./runtime.ts" -import { setupSchedule, updateSchedule } from "./schedules.ts" - -interface BrowserExampleHandle { - destroy(): Promise -} - -export const startGeneticsExample = async (mount: HTMLElement): Promise => { - const browserHost = await createGeneticsBrowserHost(mount) - const runtime = createGeneticsRuntime(browserHost.host, browserHost.nodes) - const setup = runtime.tick(setupSchedule) - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`) - - const tick = (ticker: { readonly deltaMS: number }) => { - browserHost.host.clock.deltaSeconds = Math.min(ticker.deltaMS / 1000, MAX_DELTA_SECONDS) - const ran = runtime.tick(updateSchedule) - if (!ran.ok) console.error("update failed", ran.error) - } - - browserHost.host.application.ticker.add(tick) - - return { - async destroy() { - browserHost.host.application.ticker.remove(tick) - await browserHost.destroy() - } - } -} diff --git a/examples/genetics/math.ts b/examples/genetics/math.ts deleted file mode 100644 index 75f895c..0000000 --- a/examples/genetics/math.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Ops } from "@typeonce/ritmo-math" - -export const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max) - -export const lerp = (start: number, end: number, amount: number): number => - start + (end - start) * amount - -export const normalizeXYOrZero = (x: number, y: number): { - readonly x: number - readonly y: number - readonly length: number -} => { - const magnitude = Ops.hypot(x, y) - if (magnitude <= 0.0001) { - return { - x: 0, - y: 0, - length: 0 - } - } - - return { - x: x / magnitude, - y: y / magnitude, - length: magnitude - } -} diff --git a/examples/genetics/package.json b/examples/genetics/package.json deleted file mode 100644 index 02a2452..0000000 --- a/examples/genetics/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@typeonce/ritmo-example-genetics", - "private": true, - "type": "module", - "dependencies": { - "@typeonce/ritmo": "workspace:*", - "@typeonce/ritmo-browser": "workspace:*", - "@typeonce/ritmo-pixi": "workspace:*", - "@typeonce/ritmo-render": "workspace:*" - } -} diff --git a/examples/genetics/render/board.ts b/examples/genetics/render/board.ts deleted file mode 100644 index dadd83f..0000000 --- a/examples/genetics/render/board.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Graphics } from "pixi.js" - -export const drawBoard = (board: Graphics, width: number, height: number): void => { - board.clear() - board.roundRect(0, 0, width, height, 28) - board.fill(0x0b1119) - - for (let x = 20; x < width; x += 40) { - board.moveTo(x, 16) - board.lineTo(x, height - 16) - } - for (let y = 20; y < height; y += 40) { - board.moveTo(16, y) - board.lineTo(width - 16, y) - } - - board.stroke({ - color: 0x1d2a38, - width: 1, - alpha: 0.7 - }) - board.roundRect(0, 0, width, height, 28) - board.stroke({ - color: 0x5bc0be, - width: 2, - alpha: 0.45 - }) -} diff --git a/examples/genetics/render/hud.ts b/examples/genetics/render/hud.ts deleted file mode 100644 index 07f781f..0000000 --- a/examples/genetics/render/hud.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { BrowserHud } from "../types.ts" - -const makeChip = (): HTMLSpanElement => { - const chip = document.createElement("span") - chip.className = "genetics-hud__chip" - return chip -} - -export const createHud = (): { root: HTMLDivElement; hud: BrowserHud } => { - const root = document.createElement("div") - root.className = "genetics-hud" - - const top = document.createElement("div") - top.className = "genetics-hud__top" - - const generation = makeChip() - const alive = makeChip() - const dominant = makeChip() - const births = makeChip() - const deaths = makeChip() - const status = makeChip() - top.append(generation, alive, dominant, births, deaths, status) - - const middle = document.createElement("div") - middle.className = "genetics-hud__middle" - - const scrim = document.createElement("div") - scrim.className = "genetics-hud__scrim" - - const overlay = document.createElement("div") - overlay.className = "genetics-hud__overlay" - - const title = document.createElement("h2") - title.className = "genetics-hud__title" - - const subtitle = document.createElement("p") - subtitle.className = "genetics-hud__subtitle" - - const champion = document.createElement("p") - champion.className = "genetics-hud__champion" - - const dominantLineage = document.createElement("p") - dominantLineage.className = "genetics-hud__dominant" - - overlay.append(title, subtitle, champion, dominantLineage) - middle.append(scrim, overlay) - - const footer = document.createElement("p") - footer.className = "genetics-hud__footer" - footer.textContent = - "Traits map to phenotype: sharper shapes are more aggressive, larger bodies are tougher, brighter tones live longer." - - root.append(top, middle, footer) - - return { - root, - hud: { - generation, - alive, - dominant, - births, - deaths, - status, - title, - subtitle, - champion, - dominantLineage, - footer, - scrim, - overlay - } - } -} diff --git a/examples/genetics/render/nodes.ts b/examples/genetics/render/nodes.ts deleted file mode 100644 index 4724f37..0000000 --- a/examples/genetics/render/nodes.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Graphics } from "pixi.js" - -import type { ShapeKind } from "../types.ts" - -export const makeAgentNode = ( - renderable: { - shape: ShapeKind - color: number - accent: number - alpha: number - }, - size: number -): Graphics => { - const node = new Graphics() - const radius = size - - if (renderable.shape === "circle") { - node.circle(0, 0, radius) - } else if (renderable.shape === "square") { - node.roundRect(-radius, -radius, radius * 2, radius * 2, radius * 0.5) - } else if (renderable.shape === "spike") { - for (let index = 0; index < 8; index += 1) { - const angle = (Math.PI * 2 * index) / 8 - Math.PI / 2 - const pointRadius = index % 2 === 0 ? radius * 1.34 : radius * 0.5 - const x = Math.cos(angle) * pointRadius - const y = Math.sin(angle) * pointRadius - if (index === 0) { - node.moveTo(x, y) - } else { - node.lineTo(x, y) - } - } - node.closePath() - } else if (renderable.shape === "triangle") { - node.moveTo(0, -radius * 1.2) - node.lineTo(radius * 1.1, radius * 0.95) - node.lineTo(-radius * 1.1, radius * 0.95) - node.closePath() - } else { - node.moveTo(0, -radius * 1.2) - node.lineTo(radius * 1.1, 0) - node.lineTo(0, radius * 1.2) - node.lineTo(-radius * 1.1, 0) - node.closePath() - } - - node.fill(renderable.color) - node.stroke({ - color: renderable.accent, - width: Math.max(2, radius * 0.22), - alpha: 0.95 - }) - node.alpha = renderable.alpha - return node -} - -export const makeFoodNode = (renderable: { color: number; accent: number; alpha: number }): Graphics => { - const node = new Graphics() - node.circle(0, 0, 4) - node.fill(renderable.color) - node.stroke({ - color: renderable.accent, - width: 2, - alpha: 0.9 - }) - node.alpha = renderable.alpha - return node -} diff --git a/examples/genetics/runtime.ts b/examples/genetics/runtime.ts deleted file mode 100644 index edd0aef..0000000 --- a/examples/genetics/runtime.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - FOUNDER_COUNT, - GENERATION_DURATION_SECONDS, - SUMMARY_DURATION_SECONDS -} from "./constants.ts" -import { makeFounderPool, makePopulationStats, makeRunningSummary } from "./logic.ts" -import { BrowserHost, Game, RenderNodes, SimulationPhase } from "./schema.ts" -import type { BrowserHostValue, GeneticsNodes } from "./types.ts" - -export const createGeneticsRuntime = (browser: BrowserHostValue, nodes: GeneticsNodes) => { - const initialPool = makeFounderPool(0x1f123bb, FOUNDER_COUNT) - - return Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(BrowserHost, browser), - Game.Runtime.service(RenderNodes, nodes) - ), - resources: { - DeltaTime: 1 / 60, - Arena: { - width: browser.application.screen.width, - height: browser.application.screen.height - }, - GenerationClock: { - elapsed: 0, - limit: GENERATION_DURATION_SECONDS, - transitionTimer: SUMMARY_DURATION_SECONDS - }, - GenerationIndex: 1, - RngSeed: initialPool.seed, - PopulationStats: makePopulationStats(FOUNDER_COUNT), - Summary: makeRunningSummary(), - NextGeneration: { - founders: initialPool.founders, - cause: "initial" - } - }, - machines: Game.Runtime.machines( - Game.Runtime.machine(SimulationPhase, "Running") - ) - }).value -} diff --git a/examples/genetics/schedules.ts b/examples/genetics/schedules.ts deleted file mode 100644 index f000edd..0000000 --- a/examples/genetics/schedules.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { Game, SimulationPhase } from "./schema.ts" -import { - CaptureFrameContextSystem, - CleanupDeadAgentsSystem, - MaintainFoodSystem, - ApplyMovementSystem, - ChooseIntentSystem, - QueuePhaseOutcomeSystem, - QueueResumeSystem, - ResetGenerationOnRunningEnterSystem, - ResolveAgentInteractionsSystem, - ResolveFoodAndHazardSystem, - SetupWorldSystem, - TickAgentVitalsSystem, - TickGenerationClockSystem, - TickTransitionStateSystem -} from "./systems/simulation.ts" -import { - AnimateAgentNodesSystem, - RenderNodesSystem, - SyncHudSystem -} from "./systems/render.ts" - -// GenerationScope despawns the previous generation on entering Running, before this runs. -const runningEntry = Game.Schedule( - ResetGenerationOnRunningEnterSystem, - SetupWorldSystem -) - -const stateTransitions = Game.Schedule.transitions( - Game.Schedule.onEnter(SimulationPhase, "Running", [runningEntry]) -) - -export const setupSchedule = Game.Schedule( - SetupWorldSystem, - Game.Schedule.applyDeferred(), - RenderNodesSystem, - AnimateAgentNodesSystem, - SyncHudSystem -) - -export const updateSchedule = Game.Schedule( - CaptureFrameContextSystem, - TickGenerationClockSystem, - TickAgentVitalsSystem, - ChooseIntentSystem, - ApplyMovementSystem, - ResolveFoodAndHazardSystem, - ResolveAgentInteractionsSystem, - CleanupDeadAgentsSystem, - MaintainFoodSystem, - QueuePhaseOutcomeSystem, - TickTransitionStateSystem, - QueueResumeSystem, - Game.Schedule.applyStateTransitions(stateTransitions), - Game.Schedule.applyDeferred(), - RenderNodesSystem, - AnimateAgentNodesSystem, - SyncHudSystem -) diff --git a/examples/genetics/schema.ts b/examples/genetics/schema.ts deleted file mode 100644 index 7580fe8..0000000 --- a/examples/genetics/schema.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { Descriptor, Entity, Schema } from "@typeonce/ritmo" -import type { - ArenaValue, - BrowserHostValue, - FounderSeed, - GeneticsNodes, - GenesValue, - IntentKind, - PopulationStatsValue, - RenderKind, - ShapeKind, - SummaryValue -} from "./types.ts" - -export const Root = Schema.defineRoot("GeneticsArena") - -export const Position = Descriptor.Component<{ x: number; y: number }>()("GeneticsArena/Position") -export const Velocity = Descriptor.Component<{ x: number; y: number }>()("GeneticsArena/Velocity") -export const Genes = Descriptor.Component()("GeneticsArena/Genes") -export const Agent = Descriptor.Component<{ - lineageId: number - speciesHue: number - shape: ShapeKind - size: number - maxHealth: number - maxEnergy: number - moveCost: number - attackPower: number - attackRange: number - sightRange: number - mateCompatibility: number - hostilityThreshold: number - fertilityCooldown: number - longevityLimit: number - generationBorn: number -}>()("GeneticsArena/Agent") -export const Vitals = Descriptor.Component<{ - health: number - energy: number - fullness: number - age: number - mateCooldown: number - matePartner: Entity.Handle | null - attackCooldown: number - bondProgress: number - children: number - pulse: number -}>()("GeneticsArena/Vitals") -export const Behavior = Descriptor.Component<{ - wanderAngle: number - wanderTimer: number - intentMemoryX: number - intentMemoryY: number -}>()("GeneticsArena/Behavior") -export const Intent = Descriptor.Component<{ - kind: IntentKind - x: number - y: number -}>()("GeneticsArena/Intent") -export const Renderable = Descriptor.Component<{ - kind: RenderKind - shape: ShapeKind - color: number - accent: number - alpha: number -}>()("GeneticsArena/Renderable") -export const Food = Descriptor.Component<{ - nutrition: number -}>()("GeneticsArena/Food") -export const DeltaTime = Descriptor.Resource()("GeneticsArena/DeltaTime") -export const Arena = Descriptor.Resource()("GeneticsArena/Arena") -export const GenerationClock = Descriptor.Resource<{ - elapsed: number - limit: number - transitionTimer: number -}>()("GeneticsArena/GenerationClock") -export const GenerationIndex = Descriptor.Resource()("GeneticsArena/GenerationIndex") -export const RngSeed = Descriptor.Resource()("GeneticsArena/RngSeed") -export const PopulationStats = Descriptor.Resource()("GeneticsArena/PopulationStats") -export const Summary = Descriptor.Resource()("GeneticsArena/Summary") -export const NextGeneration = Descriptor.Resource<{ - founders: ReadonlyArray - cause: "initial" | "survivors" | "extinction" -}>()("GeneticsArena/NextGeneration") - -export const BrowserHost = Descriptor.Service()("GeneticsArena/BrowserHost") -export const RenderNodes = Descriptor.Service()("GeneticsArena/RenderNodes") - -export const Game = Schema.bind( - Schema.fragment({ - components: { - Position, - Velocity, - Genes, - Agent, - Vitals, - Behavior, - Intent, - Renderable, - Food - }, - requires: [ - Schema.requires(Agent, Position, Velocity, Genes, Vitals, Behavior, Intent, Renderable), - Schema.requires(Food, Position, Velocity, Renderable) - ], - resources: { - DeltaTime, - Arena, - GenerationClock, - GenerationIndex, - RngSeed, - PopulationStats, - Summary, - NextGeneration - } - }), - Root -) - -export const schema = Game.schema -export const SimulationPhase = Game.StateMachine("GeneticsArena/SimulationPhase", ["Running", "GenerationSummary", "Extinction"] as const) - -/** One generation's food and agents: despawned when the next generation starts running. */ -export const GenerationScope = Game.EntityScope("GeneticsArena/Generation", { - despawnOnEnter: [SimulationPhase, "Running"] -}) - -export const ChangedAgentVitalsQuery = Game.Query({ - selection: { - renderable: Game.Query.read(Renderable), - agent: Game.Query.read(Agent), - vitals: Game.Query.read(Vitals) - }, - filters: [Game.Query.changed(Vitals)] -}) - -export const AgentDecisionQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - genes: Game.Query.read(Genes), - agent: Game.Query.read(Agent), - vitals: Game.Query.read(Vitals), - behavior: Game.Query.write(Behavior), - intent: Game.Query.write(Intent) - } -}) - -export const AgentSnapshotQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - genes: Game.Query.read(Genes), - agent: Game.Query.read(Agent), - vitals: Game.Query.read(Vitals) - } -}) - -export const AgentTickQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.write(Velocity), - agent: Game.Query.read(Agent), - vitals: Game.Query.write(Vitals), - intent: Game.Query.read(Intent) - } -}) - -export const FoodQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - food: Game.Query.read(Food) - } -}) - -export const AgentResolveQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - genes: Game.Query.read(Genes), - agent: Game.Query.read(Agent), - vitals: Game.Query.write(Vitals) - } -}) - diff --git a/examples/genetics/systems/render.ts b/examples/genetics/systems/render.ts deleted file mode 100644 index b6b4f81..0000000 --- a/examples/genetics/systems/render.ts +++ /dev/null @@ -1,114 +0,0 @@ - -import { RenderSync } from "@typeonce/ritmo-render" -import { clamp } from "../math.ts" -import { Agent, AgentSnapshotQuery, BrowserHost, ChangedAgentVitalsQuery, Game, GenerationClock, GenerationIndex, PopulationStats, Position, RenderNodes, Renderable, SimulationPhase, Summary } from "../schema.ts" -import { collectAgentSnapshots } from "../logic.ts" -import { makeAgentNode, makeFoodNode } from "../render/nodes.ts" - -const render = RenderSync.system(Game, { - name: "GeneticsArena/Render", - renderable: Renderable, - transform: Position, - registry: RenderNodes, - select: { agent: Game.Query.optional(Agent) }, - create: ({ renderable, data }) => - renderable.kind === "food" - ? makeFoodNode(renderable) - : makeAgentNode(renderable, data.agent.present ? data.agent.get().size : 8), - apply: (node, { transform }) => node.position.set(transform.x, transform.y) -}) - -export const RenderNodesSystem = render - -// Vitals pulse every frame, independently of movement. -export const AnimateAgentNodesSystem = Game.System( - "GeneticsArena/AnimateAgentNodes", - { - queries: { - agents: ChangedAgentVitalsQuery - }, - services: { - nodes: Game.System.service(RenderNodes) - } - }, - ({ queries, services }) => - { - for (const match of queries.agents.each()) { - const node = services.nodes.get(match.entity.id) - if (!node) { - continue - } - - const agent = match.data.agent.get() - const vitals = match.data.vitals.get() - node.rotation += (vitals.pulse - 1) * 0.03 - node.scale.set(vitals.pulse, vitals.pulse) - node.alpha = clamp(match.data.renderable.get().alpha + (agent.maxHealth - vitals.health) / agent.maxHealth * 0.06, 0.45, 1) - } - } -) - -export const SyncHudSystem = Game.System( - "GeneticsArena/SyncHud", - { - queries: { - agents: AgentSnapshotQuery - }, - resources: { - generationIndex: Game.System.changedResource(GenerationIndex), - populationStats: Game.System.readResource(PopulationStats), - summary: Game.System.readResource(Summary), - generationClock: Game.System.readResource(GenerationClock) - }, - machines: { - phase: Game.System.machine(SimulationPhase) - }, - services: { - browser: Game.System.service(BrowserHost) - } - }, - ({ queries, resources, machines, services }) => - { - const hud = services.browser.hud - const summary = resources.summary.get() - const agents = collectAgentSnapshots(queries.agents.each()) - const stats = resources.populationStats.get() - const counts = new Map() - - for (const agent of agents) { - counts.set(agent.agent.lineageId, (counts.get(agent.agent.lineageId) ?? 0) + 1) - } - - let dominantLabel = "none" - let dominantCount = 0 - for (const [lineageId, count] of counts) { - if (count > dominantCount) { - dominantLabel = String(lineageId) - dominantCount = count - } - } - - if (resources.generationIndex.changed()) hud.generation.textContent = `Generation ${resources.generationIndex.get()}` - hud.alive.textContent = `${agents.length} alive` - hud.dominant.textContent = `lineage ${dominantLabel}` - hud.births.textContent = `${stats.births} births` - hud.deaths.textContent = `${stats.deaths} deaths` - hud.status.textContent = - machines.phase.get() === "Running" - ? `${Math.max(0, resources.generationClock.get().limit - resources.generationClock.get().elapsed).toFixed(1)}s left` - : `${resources.generationClock.get().transitionTimer.toFixed(1)}s` - - hud.title.textContent = summary.title - hud.subtitle.textContent = summary.subtitle - hud.champion.textContent = summary.champion - hud.dominantLineage.textContent = summary.dominantLineage - - const overlayVisible = summary.mode !== "running" - hud.scrim.style.opacity = overlayVisible ? "1" : "0" - hud.overlay.style.opacity = overlayVisible ? "1" : "0" - hud.footer.textContent = - summary.mode === "running" - ? "Traits map to phenotype: sharper shapes are more aggressive, larger bodies are tougher, brighter tones live longer." - : "The next generation is seeded from survivors unless the arena fully collapses, in which case the ecosystem reseeds from fresh founders." - } -) diff --git a/examples/genetics/systems/simulation.ts b/examples/genetics/systems/simulation.ts deleted file mode 100644 index a559830..0000000 --- a/examples/genetics/systems/simulation.ts +++ /dev/null @@ -1,956 +0,0 @@ -import { Ops } from "@typeonce/ritmo-math" -import { - Agent, - AgentDecisionQuery, - AgentResolveQuery, - AgentSnapshotQuery, - AgentTickQuery, - Arena, - BrowserHost, - DeltaTime, - Food, - FoodQuery, - Game, - GenerationClock, - GenerationIndex, - GenerationScope, - NextGeneration, - PopulationStats, - RngSeed, - SimulationPhase, - Summary -} from "../schema.ts" -import { - collectAgentSnapshots, - foodMargin, - formatGene, - founderMargin, - founderMargin as initialSpawnMargin, - geneDistance, - makeFounderPool, - makePopulationStats, - makeRunningSummary, - normalizeVector, - offspringMargin, - randomBetween, - randomUnit, - sampleFoundersFromSurvivors, - spawnAgentDraft, - spawnFoodDraft, - wrap -} from "../logic.ts" -import { - ATTACK_CONTACT_PADDING, - EXTINCTION_DURATION_SECONDS, - FOOD_TARGET, - FOUNDER_COUNT, - GENERATION_DURATION_SECONDS, - INITIAL_FOOD_COUNT, - MATING_CONTACT_RADIUS, - SUMMARY_DURATION_SECONDS -} from "../constants.ts" -import { clamp, lerp } from "../math.ts" -import type { GenesValue, IntentKind } from "../types.ts" - -export const SetupWorldSystem = Game.System( - "GeneticsArena/SetupWorld", - { - resources: { - arena: Game.System.readResource(Arena), - generationIndex: Game.System.readResource(GenerationIndex), - nextGeneration: Game.System.readResource(NextGeneration), - rngSeed: Game.System.writeResource(RngSeed), - populationStats: Game.System.writeResource(PopulationStats), - summary: Game.System.writeResource(Summary) - }, - commands: Game.System.commands(Game.System.spawns(Food, Agent)) - }, - ({ commands, resources }) => - { - const arena = resources.arena.get() - const dynamicFoodMargin = foodMargin(arena) - const dynamicSpawnMargin = initialSpawnMargin(arena) - let seed = resources.rngSeed.get() - - for (let index = 0; index < INITIAL_FOOD_COUNT; index += 1) { - const xDraw = randomBetween(seed, dynamicFoodMargin, arena.width - dynamicFoodMargin) - seed = xDraw.seed - const yDraw = randomBetween(seed, dynamicFoodMargin, arena.height - dynamicFoodMargin) - seed = yDraw.seed - commands.spawnIn(GenerationScope, spawnFoodDraft({ x: xDraw.value, y: yDraw.value })) - } - - const founders = resources.nextGeneration.get().founders - for (const founder of founders) { - const xDraw = randomBetween(seed, dynamicSpawnMargin, arena.width - dynamicSpawnMargin) - seed = xDraw.seed - const yDraw = randomBetween(seed, dynamicSpawnMargin, arena.height - dynamicSpawnMargin) - seed = yDraw.seed - const angleDraw = randomBetween(seed, 0, Math.PI * 2) - seed = angleDraw.seed - commands.spawnIn( - GenerationScope, - spawnAgentDraft( - founder, - { x: xDraw.value, y: yDraw.value }, - resources.generationIndex.get(), - angleDraw.value - ) - ) - } - - resources.rngSeed.set(seed) - resources.populationStats.set(makePopulationStats(founders.length)) - resources.summary.set(makeRunningSummary()) - } -) - -export const ResetGenerationOnRunningEnterSystem = Game.System( - "GeneticsArena/ResetGenerationOnRunningEnter", - { - resources: { - generationClock: Game.System.writeResource(GenerationClock), - summary: Game.System.writeResource(Summary) - } - }, - ({ resources }) => - { - // The previous generation was despawned with GenerationScope on entering Running. - resources.generationClock.set({ - elapsed: 0, - limit: GENERATION_DURATION_SECONDS, - transitionTimer: 0 - }) - resources.summary.set(makeRunningSummary()) - } -) - -// The browser host owns viewport size and ticker timing. This system copies that -// state into ECS resources so later systems can stay pure and typed. -export const CaptureFrameContextSystem = Game.System( - "GeneticsArena/CaptureFrameContext", - { - resources: { - deltaTime: Game.System.writeResource(DeltaTime), - arena: Game.System.writeResource(Arena) - }, - services: { - browser: Game.System.service(BrowserHost) - } - }, - ({ resources, services }) => - { - resources.deltaTime.set(services.browser.clock.deltaSeconds) - resources.arena.set({ - width: services.browser.application.screen.width, - height: services.browser.application.screen.height - }) - } -) - -export const TickGenerationClockSystem = Game.System( - "GeneticsArena/TickGenerationClock", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - resources: { - deltaTime: Game.System.readResource(DeltaTime), - generationClock: Game.System.writeResource(GenerationClock) - } - }, - ({ resources }) => - { - const dt = resources.deltaTime.get() - resources.generationClock.update((clock) => ({ - ...clock, - elapsed: clock.elapsed + dt - })) - } -) - -export const TickAgentVitalsSystem = Game.System( - "GeneticsArena/TickAgentVitals", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - agents: AgentTickQuery - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime) - } - }, - ({ queries, resources }) => - { - const dt = resources.deltaTime.get() - for (const match of queries.agents.each()) { - const agent = match.data.agent.get() - match.data.vitals.update((vitals) => { - const age = vitals.age + dt - const mateCooldown = Math.max(0, vitals.mateCooldown - dt) - const attackCooldown = Math.max(0, vitals.attackCooldown - dt) - const bondProgress = Math.max(0, vitals.bondProgress - dt * 0.9) - const pulse = Math.max(0.78, vitals.pulse - dt * 1.2) - const starvation = vitals.energy <= 0 ? dt * 15 : vitals.energy < agent.maxEnergy * 0.2 ? dt * 4.4 : 0 - const extraOldAge = age > agent.longevityLimit ? (age - agent.longevityLimit) * 2.5 * dt : 0 - - return { - ...vitals, - age, - mateCooldown, - matePartner: bondProgress <= 0.02 ? null : vitals.matePartner, - attackCooldown, - bondProgress, - pulse, - fullness: Math.max(0, vitals.fullness - dt * lerp(0.15, 0.26, agent.moveCost / 11.6)), - health: Math.max(0, vitals.health - starvation - extraOldAge), - energy: clamp(vitals.energy - dt * lerp(2.4, 4.5, agent.moveCost / 11.6), 0, agent.maxEnergy) - } - }) - } - } -) - -// Intent selection is the densest part of the example. The comments explain the -// role of each pressure instead of restating what the API calls do. -export const ChooseIntentSystem = Game.System( - "GeneticsArena/ChooseIntent", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - agents: AgentDecisionQuery, - foods: FoodQuery - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime) - } - }, - ({ queries, resources }) => - { - const dt = resources.deltaTime.get() - const foods = queries.foods.each().map((match) => ({ - position: match.data.position.get() - })) - const agents = collectAgentSnapshots(queries.agents.each()) - - for (const match of queries.agents.each()) { - const position = match.data.position.get() - const genes = match.data.genes.get() - const agent = match.data.agent.get() - const vitals = match.data.vitals.get() - - let targetKind: IntentKind = "wander" - let vectorX = 0 - let vectorY = 0 - let nearestThreatDistance = Number.POSITIVE_INFINITY - let threatVector = { x: 0, y: 0 } - let attackVector = { x: 0, y: 0 } - let attackDistance = Number.POSITIVE_INFINITY - let mateVector = { x: 0, y: 0 } - let mateDistance = Number.POSITIVE_INFINITY - let foodVector = { x: 0, y: 0 } - let foodDistance = Number.POSITIVE_INFINITY - let crowdRepulsion = { x: 0, y: 0 } - - for (const other of agents) { - if (other.entityId === match.entity.id) { - continue - } - - const deltaX = other.position.x - position.x - const deltaY = other.position.y - position.y - const distance = Ops.hypot(deltaX, deltaY) - if (distance > agent.sightRange || distance < 0.001) { - continue - } - - const geneticGap = geneDistance(genes, other.genes) - const compatible = geneticGap <= Math.min(agent.mateCompatibility, other.agent.mateCompatibility) - const hostile = - geneticGap >= Math.max(agent.hostilityThreshold, other.agent.hostilityThreshold) && - (genes.aggression >= 0.3 || other.genes.aggression >= 0.3) - const normalized = normalizeVector(deltaX, deltaY) - if (distance < agent.size * 4.4) { - const pressure = (agent.size * 4.4 - distance) / (agent.size * 4.4) - crowdRepulsion = { - x: crowdRepulsion.x - normalized.x * pressure * 1.2, - y: crowdRepulsion.y - normalized.y * pressure * 1.2 - } - } - - if (hostile) { - const otherStrength = other.agent.attackPower + other.vitals.health * 0.18 - const selfStrength = agent.attackPower + vitals.health * 0.18 - const threatened = - otherStrength >= selfStrength * 0.72 || - vitals.energy < agent.maxEnergy * 0.52 || - vitals.health < agent.maxHealth * 0.58 - if (threatened && distance < nearestThreatDistance) { - nearestThreatDistance = distance - threatVector = { - x: -normalized.x * (1 + other.genes.aggression), - y: -normalized.y * (1 + other.genes.aggression) - } - } - if (genes.aggression >= 0.28 && vitals.energy > agent.maxEnergy * 0.18 && distance < attackDistance) { - attackDistance = distance - attackVector = { - x: normalized.x, - y: normalized.y - } - } - continue - } - - if ( - compatible && - vitals.mateCooldown <= 0 && - (vitals.matePartner === null || vitals.matePartner === other.entityId) && - (other.vitals.matePartner === null || other.vitals.matePartner === match.entity.id) && - other.vitals.mateCooldown <= 0 && - vitals.energy > agent.maxEnergy * 0.74 && - vitals.health > agent.maxHealth * 0.68 && - other.vitals.energy > other.agent.maxEnergy * 0.74 && - distance < mateDistance - ) { - mateDistance = distance - mateVector = { - x: normalized.x, - y: normalized.y - } - } - } - - for (const food of foods) { - const deltaX = food.position.x - position.x - const deltaY = food.position.y - position.y - const distance = Ops.hypot(deltaX, deltaY) - if (distance > agent.sightRange || distance >= foodDistance) { - continue - } - - const normalized = normalizeVector(deltaX, deltaY) - foodDistance = distance - foodVector = { - x: normalized.x, - y: normalized.y - } - } - - match.data.behavior.update((behavior) => { - let nextBehavior = behavior - if (behavior.wanderTimer <= 0) { - nextBehavior = { - wanderAngle: behavior.wanderAngle + lerp(-0.42, 0.42, genes.sight) + dt * lerp(-0.28, 0.28, genes.speed), - wanderTimer: lerp(1.8, 3.8, 1 - genes.speed), - intentMemoryX: behavior.intentMemoryX, - intentMemoryY: behavior.intentMemoryY - } - } else { - nextBehavior = { - ...behavior, - wanderTimer: behavior.wanderTimer - dt - } - } - - let direction = { - x: Ops.cos(nextBehavior.wanderAngle), - y: Ops.sin(nextBehavior.wanderAngle) - } - - const full = vitals.fullness >= 0.62 || vitals.energy >= agent.maxEnergy * 0.76 - const hungerCritical = vitals.energy < agent.maxEnergy * 0.24 && vitals.fullness < 0.22 - const hungerPanic = vitals.energy < agent.maxEnergy * 0.12 && vitals.fullness < 0.12 - const matingReady = - mateDistance < agent.sightRange * 0.64 && - vitals.energy > agent.maxEnergy * 0.44 && - vitals.health > agent.maxHealth * 0.56 && - vitals.fullness > 0.28 && - (vitals.matePartner === null || vitals.bondProgress > 0.08) - const predatorBias = genes.aggression >= 0.8 || genes.attack >= 0.84 - const fightReady = - attackDistance < agent.attackRange + (predatorBias ? 104 : 76) && - vitals.attackCooldown <= 0 && - vitals.energy > (predatorBias ? 5 : 8) && - (predatorBias || full || genes.aggression >= 0.42 || genes.attack >= 0.68 || vitals.fullness > 0.38) - if ( - nearestThreatDistance < agent.sightRange * 0.62 || - (nearestThreatDistance < agent.sightRange * 0.85 && (vitals.health < agent.maxHealth * 0.54 || hungerPanic)) - ) { - targetKind = "flee" - direction = threatVector - } else if (fightReady) { - targetKind = "attack" - direction = attackVector - } else if (matingReady) { - targetKind = "seekMate" - direction = { - x: mateVector.x * 1.18 + crowdRepulsion.x * 0.24, - y: mateVector.y * 1.18 + crowdRepulsion.y * 0.24 - } - } else if (!predatorBias && !full && (hungerCritical || foodDistance < agent.sightRange * 0.52)) { - targetKind = "seekFood" - direction = { - x: foodVector.x * 1.36 + crowdRepulsion.x * 0.12, - y: foodVector.y * 1.36 + crowdRepulsion.y * 0.12 - } - } else if (predatorBias && attackDistance < agent.attackRange + 128 && vitals.attackCooldown <= 0) { - targetKind = "attack" - direction = { - x: attackVector.x * 1.18, - y: attackVector.y * 1.18 - } - } else { - direction = { - x: direction.x * 0.96 + crowdRepulsion.x * 0.32, - y: direction.y * 0.96 + crowdRepulsion.y * 0.32 - } - } - - const desired = normalizeVector(direction.x, direction.y) - const smoothed = normalizeVector( - desired.x * 0.38 + behavior.intentMemoryX * 0.62, - desired.y * 0.38 + behavior.intentMemoryY * 0.62 - ) - vectorX = smoothed.x - vectorY = smoothed.y - return { - ...nextBehavior, - intentMemoryX: smoothed.x, - intentMemoryY: smoothed.y - } - }) - - match.data.intent.set({ - kind: targetKind, - x: vectorX, - y: vectorY - }) - } - } -) - -export const ApplyMovementSystem = Game.System( - "GeneticsArena/ApplyMovement", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - agents: AgentTickQuery - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime), - arena: Game.System.readResource(Arena) - } - }, - ({ queries, resources }) => - { - const dt = resources.deltaTime.get() - const arena = resources.arena.get() - - for (const match of queries.agents.each()) { - const intent = match.data.intent.get() - const agent = match.data.agent.get() - const currentVelocity = match.data.velocity.get() - const normalized = normalizeVector(intent.x, intent.y) - const speed = lerp(22, 92, agent.moveCost / 11.6) - const position = match.data.position.get() - const velocity = { - x: currentVelocity.x * 0.58 + normalized.x * speed * 0.42, - y: currentVelocity.y * 0.58 + normalized.y * speed * 0.42 - } - - match.data.velocity.set(velocity) - match.data.position.set({ - x: wrap(position.x + velocity.x * dt, arena.width), - y: wrap(position.y + velocity.y * dt, arena.height) - }) - - match.data.vitals.update((vitals) => ({ - ...vitals, - fullness: Math.max(0, vitals.fullness - dt * (normalized.length > 0 ? 0.13 : 0.04)), - energy: clamp(vitals.energy - dt * agent.moveCost * (normalized.length > 0 ? 0.38 : 0.12), 0, agent.maxEnergy) - })) - } - } -) - -export const ResolveFoodAndHazardSystem = Game.System( - "GeneticsArena/ResolveFoodAndHazard", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - agents: AgentTickQuery, - foods: FoodQuery - }, - commands: Game.System.commands(Game.System.despawns(Food)) - }, - ({ queries, commands }) => - { - const foods = queries.foods.each().map((match) => ({ - entity: match.entity, - position: match.data.position.get(), - nutrition: match.data.food.get().nutrition - })) - for (const match of queries.agents.each()) { - const position = match.data.position.get() - const agent = match.data.agent.get() - - for (const food of foods) { - const distance = Ops.hypot(food.position.x - position.x, food.position.y - position.y) - if (distance > agent.size + 8) { - continue - } - - commands.despawn(food.entity) - match.data.vitals.update((vitals) => ({ - ...vitals, - health: clamp(vitals.health + food.nutrition * 0.2, 0, agent.maxHealth), - energy: clamp(vitals.energy + food.nutrition, 0, agent.maxEnergy), - fullness: clamp(vitals.fullness + 0.4, 0, 1), - pulse: 1.28 - })) - } - } - } -) - -export const ResolveAgentInteractionsSystem = Game.System( - "GeneticsArena/ResolveAgentInteractions", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - agents: AgentSnapshotQuery, - resolve: AgentResolveQuery - }, - resources: { - arena: Game.System.readResource(Arena), - deltaTime: Game.System.readResource(DeltaTime), - generationIndex: Game.System.readResource(GenerationIndex), - rngSeed: Game.System.writeResource(RngSeed), - populationStats: Game.System.writeResource(PopulationStats) - }, - commands: Game.System.commands(Game.System.spawns(Agent)) - }, - ({ queries, resources, commands }) => - { - const dt = resources.deltaTime.get() - const arena = resources.arena.get() - const childMargin = offspringMargin(arena) - let seed = resources.rngSeed.get() - const snapshots = collectAgentSnapshots(queries.agents.each()) - const engagedPairs = new Set() - - for (let index = 0; index < snapshots.length; index += 1) { - const left = snapshots[index] - if (!left) { - continue - } - - for (let otherIndex = index + 1; otherIndex < snapshots.length; otherIndex += 1) { - const right = snapshots[otherIndex] - if (!right) { - continue - } - - const deltaX = right.position.x - left.position.x - const deltaY = right.position.y - left.position.y - const distance = Ops.hypot(deltaX, deltaY) - const gap = geneDistance(left.genes, right.genes) - const compatible = gap <= Math.min(left.agent.mateCompatibility, right.agent.mateCompatibility) - const hostile = - gap >= Math.max(left.agent.hostilityThreshold, right.agent.hostilityThreshold) && - (left.genes.aggression >= 0.3 || right.genes.aggression >= 0.3) - - if (!compatible && !hostile) { - continue - } - - const leftLive = queries.resolve.getHandle(left.handle) - const rightLive = queries.resolve.getHandle(right.handle) - if (!leftLive.ok || !rightLive.ok) { - continue - } - - const leftVitals = leftLive.value.data.vitals.get() - const rightVitals = rightLive.value.data.vitals.get() - const leftLockedToRight = leftVitals.matePartner === right.entityId - const rightLockedToLeft = rightVitals.matePartner === left.entityId - const leftAvailableForMate = leftVitals.matePartner === null || leftLockedToRight - const rightAvailableForMate = rightVitals.matePartner === null || rightLockedToLeft - - if ( - compatible && - distance <= left.agent.size + right.agent.size + MATING_CONTACT_RADIUS && - leftVitals.mateCooldown <= 0 && - rightVitals.mateCooldown <= 0 && - leftAvailableForMate && - rightAvailableForMate && - !engagedPairs.has(left.entityId) && - !engagedPairs.has(right.entityId) && - leftVitals.energy > left.agent.maxEnergy * 0.58 && - rightVitals.energy > right.agent.maxEnergy * 0.58 && - leftVitals.health > left.agent.maxHealth * 0.58 && - rightVitals.health > right.agent.maxHealth * 0.58 - ) { - const bondGain = dt * lerp(0.42, 0.96, (left.genes.fertility + right.genes.fertility) * 0.5) - leftLive.value.data.vitals.update((vitals) => ({ - ...vitals, - matePartner: Game.Entity.handle(rightLive.value.entity.id, Agent), - bondProgress: Math.min(2.4, vitals.bondProgress + bondGain), - pulse: 1.08 - })) - rightLive.value.data.vitals.update((vitals) => ({ - ...vitals, - matePartner: Game.Entity.handle(leftLive.value.entity.id, Agent), - bondProgress: Math.min(2.4, vitals.bondProgress + bondGain), - pulse: 1.08 - })) - engagedPairs.add(left.entityId) - engagedPairs.add(right.entityId) - - const bondedLeft = leftLive.value.data.vitals.get() - const bondedRight = rightLive.value.data.vitals.get() - if (bondedLeft.bondProgress >= 1.7 && bondedRight.bondProgress >= 1.7) { - const mix = (leftValue: number, rightValue: number) => { - const blend = randomBetween(seed, 0.35, 0.65) - seed = blend.seed - const mutation = randomBetween(seed, -0.06, 0.06) - seed = mutation.seed - return clamp(lerp(leftValue, rightValue, blend.value) + mutation.value, 0.04, 0.98) - } - - const childGenes: GenesValue = { - speed: mix(left.genes.speed, right.genes.speed), - resilience: mix(left.genes.resilience, right.genes.resilience), - attack: mix(left.genes.attack, right.genes.attack), - longevity: mix(left.genes.longevity, right.genes.longevity), - sight: mix(left.genes.sight, right.genes.sight), - fertility: mix(left.genes.fertility, right.genes.fertility), - aggression: mix(left.genes.aggression, right.genes.aggression) - } - const lineage = randomUnit(seed) - seed = lineage.seed - const hueMutation = randomBetween(seed, -12, 12) - seed = hueMutation.seed - const founder = { - lineageId: lineage.value >= 0.5 ? left.agent.lineageId : right.agent.lineageId, - speciesHue: clamp(lerp(left.agent.speciesHue, right.agent.speciesHue, 0.5) + hueMutation.value, 0, 359), - genes: childGenes - } - const offsetX = randomBetween(seed, -18, 18) - seed = offsetX.seed - const offsetY = randomBetween(seed, -18, 18) - seed = offsetY.seed - const wanderAngle = randomBetween(seed, 0, Math.PI * 2) - seed = wanderAngle.seed - commands.spawnIn( - GenerationScope, - spawnAgentDraft( - founder, - { - x: clamp((left.position.x + right.position.x) * 0.5 + offsetX.value, childMargin, arena.width - childMargin), - y: clamp((left.position.y + right.position.y) * 0.5 + offsetY.value, childMargin, arena.height - childMargin) - }, - resources.generationIndex.get(), - wanderAngle.value - ) - ) - leftLive.value.data.vitals.update((vitals) => ({ - ...vitals, - bondProgress: 0, - mateCooldown: left.agent.fertilityCooldown, - matePartner: null, - energy: clamp(vitals.energy - left.agent.maxEnergy * 0.36, 0, left.agent.maxEnergy), - fullness: Math.max(0, vitals.fullness - 0.22), - children: vitals.children + 1, - pulse: 1.34 - })) - rightLive.value.data.vitals.update((vitals) => ({ - ...vitals, - bondProgress: 0, - mateCooldown: right.agent.fertilityCooldown, - matePartner: null, - energy: clamp(vitals.energy - right.agent.maxEnergy * 0.36, 0, right.agent.maxEnergy), - fullness: Math.max(0, vitals.fullness - 0.22), - children: vitals.children + 1, - pulse: 1.34 - })) - resources.populationStats.update((stats) => ({ - births: stats.births + 1, - deaths: stats.deaths, - peak: Math.max(stats.peak, stats.births - stats.deaths + 1) - })) - } - continue - } - - if (hostile && distance <= Math.max(left.agent.attackRange, right.agent.attackRange) + ATTACK_CONTACT_PADDING) { - if (leftVitals.attackCooldown <= 0 && leftVitals.energy > 6) { - const damage = Math.max(3.5, left.agent.attackPower - right.genes.resilience * 7.4) - rightLive.value.data.vitals.update((vitals) => ({ - ...vitals, - health: Math.max(0, vitals.health - damage), - matePartner: null, - bondProgress: 0, - pulse: 1.24 - })) - leftLive.value.data.vitals.update((vitals) => ({ - ...vitals, - attackCooldown: lerp(0.78, 0.18, left.genes.attack), - energy: clamp(vitals.energy - 2.6, 0, left.agent.maxEnergy), - fullness: Math.max(0, vitals.fullness - 0.06), - pulse: 1.12 - })) - } - - const refreshedRight = rightLive.value.data.vitals.get() - if (refreshedRight.attackCooldown <= 0 && refreshedRight.energy > 6) { - const damage = Math.max(3.5, right.agent.attackPower - left.genes.resilience * 7.4) - leftLive.value.data.vitals.update((vitals) => ({ - ...vitals, - health: Math.max(0, vitals.health - damage), - matePartner: null, - bondProgress: 0, - pulse: 1.24 - })) - rightLive.value.data.vitals.update((vitals) => ({ - ...vitals, - attackCooldown: lerp(0.78, 0.18, right.genes.attack), - energy: clamp(vitals.energy - 2.6, 0, right.agent.maxEnergy), - fullness: Math.max(0, vitals.fullness - 0.06), - pulse: 1.12 - })) - } - } - } - } - - resources.rngSeed.set(seed) - } -) - -export const CleanupDeadAgentsSystem = Game.System( - "GeneticsArena/CleanupDeadAgents", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - agents: AgentSnapshotQuery - }, - resources: { - populationStats: Game.System.writeResource(PopulationStats) - }, - commands: Game.System.commands(Game.System.despawns(Agent)) - }, - ({ queries, commands, resources }) => - { - let deaths = 0 - for (const match of queries.agents.each()) { - const vitals = match.data.vitals.get() - if (vitals.health > 0) { - continue - } - - deaths += 1 - commands.despawn(match.entity) - } - - if (deaths > 0) { - resources.populationStats.update((stats) => ({ - births: stats.births, - deaths: stats.deaths + deaths, - peak: stats.peak - })) - } - } -) - -export const MaintainFoodSystem = Game.System( - "GeneticsArena/MaintainFood", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - foods: FoodQuery - }, - resources: { - arena: Game.System.readResource(Arena), - rngSeed: Game.System.writeResource(RngSeed) - }, - commands: Game.System.commands(Game.System.spawns(Food)) - }, - ({ queries, commands, resources }) => - { - const foods = queries.foods.each() - if (foods.length >= FOOD_TARGET) { - return - } - - let seed = resources.rngSeed.get() - const arena = resources.arena.get() - const dynamicFoodMargin = foodMargin(arena) - const spawnRoll = randomUnit(seed) - seed = spawnRoll.seed - if (spawnRoll.value < 0.48) { - resources.rngSeed.set(seed) - return - } - - const deficit = FOOD_TARGET - foods.length - const refillCount = Math.min(deficit, 3) - for (let index = 0; index < refillCount; index += 1) { - const xDraw = randomBetween(seed, dynamicFoodMargin, arena.width - dynamicFoodMargin) - seed = xDraw.seed - const yDraw = randomBetween(seed, dynamicFoodMargin, arena.height - dynamicFoodMargin) - seed = yDraw.seed - commands.spawnIn(GenerationScope, spawnFoodDraft({ x: xDraw.value, y: yDraw.value })) - } - resources.rngSeed.set(seed) - } -) - -export const QueuePhaseOutcomeSystem = Game.System( - "GeneticsArena/QueuePhaseOutcome", - { - when: [Game.Condition.inState(SimulationPhase, "Running")], - queries: { - agents: AgentSnapshotQuery - }, - resources: { - generationClock: Game.System.writeResource(GenerationClock), - rngSeed: Game.System.writeResource(RngSeed), - nextGeneration: Game.System.writeResource(NextGeneration), - summary: Game.System.writeResource(Summary) - }, - nextMachines: { - phase: Game.System.nextState(SimulationPhase) - } - }, - ({ queries, resources, nextMachines }) => - { - const survivors = collectAgentSnapshots(queries.agents.each()) - if (survivors.length === 0) { - const pool = makeFounderPool(resources.rngSeed.get(), FOUNDER_COUNT) - resources.rngSeed.set(pool.seed) - resources.nextGeneration.set({ - founders: pool.founders, - cause: "extinction" - }) - resources.generationClock.set({ - elapsed: resources.generationClock.get().elapsed, - limit: resources.generationClock.get().limit, - transitionTimer: EXTINCTION_DURATION_SECONDS - }) - resources.summary.set({ - mode: "extinction", - title: "Extinction event.", - subtitle: "No lineage survived the arena. The environment will reseed from scratch.", - champion: "No champion: every phenotype collapsed.", - dominantLineage: "Dominant lineage: none" - }) - nextMachines.phase.set("Extinction") - return - } - - const clock = resources.generationClock.get() - if (clock.elapsed < clock.limit) { - return - } - - const lineageCounts = new Map() - const initialChampion = survivors[0] - if (!initialChampion) { - return - } - let champion = initialChampion - for (const survivor of survivors) { - lineageCounts.set(survivor.agent.lineageId, (lineageCounts.get(survivor.agent.lineageId) ?? 0) + 1) - const championScore = - champion.vitals.health + champion.vitals.energy + champion.vitals.children * 18 + champion.genes.longevity * 20 - const nextScore = - survivor.vitals.health + survivor.vitals.energy + survivor.vitals.children * 18 + survivor.genes.longevity * 20 - if (nextScore > championScore) { - champion = survivor - } - } - - let dominantLineageId = champion.agent.lineageId - let dominantCount = 0 - for (const [lineageId, count] of lineageCounts) { - if (count > dominantCount) { - dominantLineageId = lineageId - dominantCount = count - } - } - - const founders = sampleFoundersFromSurvivors(resources.rngSeed.get(), survivors, FOUNDER_COUNT) - resources.rngSeed.set(founders.seed) - resources.nextGeneration.set({ - founders: founders.founders, - cause: "survivors" - }) - resources.generationClock.set({ - elapsed: clock.elapsed, - limit: clock.limit, - transitionTimer: SUMMARY_DURATION_SECONDS - }) - resources.summary.set({ - mode: "summary", - title: `Generation resolved: lineage ${dominantLineageId} leads.`, - subtitle: `${survivors.length} survivors remain after ${clock.elapsed.toFixed(1)} seconds of pressure.`, - champion: - `Champion genes spd ${formatGene(champion.genes.speed)} atk ${formatGene(champion.genes.attack)} ` + - `res ${formatGene(champion.genes.resilience)} long ${formatGene(champion.genes.longevity)}`, - dominantLineage: `Dominant lineage ${dominantLineageId} holds ${dominantCount} survivors.` - }) - nextMachines.phase.set("GenerationSummary") - } -) - -export const TickTransitionStateSystem = Game.System( - "GeneticsArena/TickTransitionState", - { - when: [ - Game.Condition.or( - Game.Condition.inState(SimulationPhase, "GenerationSummary"), - Game.Condition.inState(SimulationPhase, "Extinction") - ) - ], - resources: { - deltaTime: Game.System.readResource(DeltaTime), - generationClock: Game.System.writeResource(GenerationClock) - } - }, - ({ resources }) => - { - const dt = resources.deltaTime.get() - resources.generationClock.update((clock) => ({ - ...clock, - transitionTimer: Math.max(0, clock.transitionTimer - dt) - })) - } -) - -export const QueueResumeSystem = Game.System( - "GeneticsArena/QueueResume", - { - when: [ - Game.Condition.or( - Game.Condition.inState(SimulationPhase, "GenerationSummary"), - Game.Condition.inState(SimulationPhase, "Extinction") - ) - ], - resources: { - generationClock: Game.System.readResource(GenerationClock), - generationIndex: Game.System.writeResource(GenerationIndex) - }, - nextMachines: { - phase: Game.System.nextState(SimulationPhase) - } - }, - ({ resources, nextMachines }) => - { - if (resources.generationClock.get().transitionTimer > 0) { - return - } - - resources.generationIndex.update((value) => value + 1) - nextMachines.phase.set("Running") - } -) diff --git a/examples/genetics/tsconfig.json b/examples/genetics/tsconfig.json deleted file mode 100644 index eca7f23..0000000 --- a/examples/genetics/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["./**/*.ts"] -} diff --git a/examples/genetics/types.ts b/examples/genetics/types.ts deleted file mode 100644 index 4cab0ea..0000000 --- a/examples/genetics/types.ts +++ /dev/null @@ -1,109 +0,0 @@ -import type { Entity } from "@typeonce/ritmo" -import type { NodeRegistry } from "@typeonce/ritmo-render" -import type { Application, Container, Graphics } from "pixi.js" -import type { Agent, Root } from "./schema.ts" - -export type GeneValue = number - -export type GenesValue = { - speed: GeneValue - resilience: GeneValue - attack: GeneValue - longevity: GeneValue - sight: GeneValue - fertility: GeneValue - aggression: GeneValue -} - -export type FounderSeed = { - lineageId: number - speciesHue: number - genes: GenesValue -} - -export type SummaryValue = { - mode: "running" | "summary" | "extinction" - title: string - subtitle: string - champion: string - dominantLineage: string -} - -export type Phase = "Running" | "GenerationSummary" | "Extinction" -export type IntentKind = "wander" | "seekFood" | "seekMate" | "attack" | "flee" -export type ShapeKind = "circle" | "square" | "triangle" | "diamond" | "spike" -export type RenderKind = "agent" | "food" | "hazard" - -export type PopulationStatsValue = { - births: number - deaths: number - peak: number -} - -export type ArenaValue = { - width: number - height: number -} - -export type BrowserHud = { - generation: HTMLSpanElement - alive: HTMLSpanElement - dominant: HTMLSpanElement - births: HTMLSpanElement - deaths: HTMLSpanElement - status: HTMLSpanElement - title: HTMLHeadingElement - subtitle: HTMLParagraphElement - champion: HTMLParagraphElement - dominantLineage: HTMLParagraphElement - footer: HTMLParagraphElement - scrim: HTMLDivElement - overlay: HTMLDivElement -} - -export type GeneticsNodes = NodeRegistry.NodeRegistry - -export type BrowserHostValue = { - application: Application - scene: Container - clock: { - deltaSeconds: number - } - hud: BrowserHud -} - -export type AgentSnapshot = { - handle: Entity.Handle - entityId: number - position: { x: number; y: number } - genes: GenesValue - agent: { - lineageId: number - speciesHue: number - shape: ShapeKind - size: number - maxHealth: number - maxEnergy: number - moveCost: number - attackPower: number - attackRange: number - sightRange: number - mateCompatibility: number - hostilityThreshold: number - fertilityCooldown: number - longevityLimit: number - generationBorn: number - } - vitals: { - health: number - energy: number - fullness: number - age: number - mateCooldown: number - matePartner: number | null - attackCooldown: number - bondProgress: number - children: number - pulse: number - } -} diff --git a/examples/pixi/browser.ts b/examples/pixi/browser.ts deleted file mode 100644 index 06f80d2..0000000 --- a/examples/pixi/browser.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { startPlatformerExample } from "../platformer/main.ts" -import { startPixiExample } from "./main.ts" -import { startPokemonExample } from "./pokemon.ts" -import { startSnakeExample } from "./snake.ts" -import { startSpaceInvadersExample } from "./space-invaders.ts" -import { startStateMachineExample } from "./state-machine/main.ts" - -interface BrowserExampleHandle { - destroy(): Promise -} - -type ExampleId = - | "pixi" - | "platformer" - | "pokemon" - | "snake" - | "space-invaders" - | "state-machine" - -type ExampleDefinition = { - readonly id: ExampleId - readonly label: string - readonly eyebrow: string - readonly title: string - readonly description: string - readonly meta: ReadonlyArray - readonly start: (mount: HTMLElement) => Promise -} - -const examples: ReadonlyArray = [ - { - id: "pixi", - label: "Pixi Motion", - eyebrow: "PixiJS loop, ritmo ECS", - title: "External runtime, typed motion systems.", - description: - "Pixi owns the canvas and ticker. The ECS side owns descriptors, schema, resources, systems, schedules, and the sync step that projects ECS state into renderer-owned sprites.", - meta: ["Setup schedule: spawn scene", "Update: input, motion, bounce, sync", "Renderer objects live outside ECS"], - start: startPixiExample - }, - { - id: "platformer", - label: "Platformer", - eyebrow: "Mario-like foundation", - title: "Explicit kinematic platforming with typed lose and restart flow.", - description: - "This example keeps the platformer controller deterministic and ECS-owned: input, jump, gravity, collision, loss detection, restart transitions, camera follow, and Pixi host sync all stay explicit so later coins, enemies, and power-ups can extend the same architecture.", - meta: ["Arrow keys or A/D move", "Space, W, or ArrowUp jumps", "Shift runs, holes trigger Lost, click restarts"], - start: startPlatformerExample - }, - { - id: "state-machine", - label: "State Machine", - eyebrow: "Typed phase orchestration demo", - title: "Two typed machines coordinate countdown, play, pause, win, and lose.", - description: - "This example exists specifically to show the finite-state-machine API. Session and round modes are modeled separately, systems queue next states, schedules apply transitions explicitly, transition bundles reset the round, and later systems react through typed transition events.", - meta: ["SessionState + RoundState", "Explicit applyStateTransitions() boundaries", "Transition bundles and transition events"], - start: startStateMachineExample - }, - { - id: "pokemon", - label: "Pokemon", - eyebrow: "Grid movement demo", - title: "Intent, collision, and apply phases on a Pixi grid.", - description: - "This keeps the Pokemon-style ECS flow intact: keyboard input becomes movement intent, collisions veto illegal targets, and a sync system mirrors ECS positions into Pixi tiles.", - meta: ["Arrows or WASD to move", "Fresh runtime per switch", "Pixi-backed browser integration"], - start: startPokemonExample - }, - { - id: "snake", - label: "Snake", - eyebrow: "Event and lookup demo", - title: "Ordered ECS phases on a visible Pixi board.", - description: - "This browser tab keeps the original Snake ECS logic and only adds a Pixi render bridge. It is the intermediate validation step for visible grid-based ECS rendering.", - meta: ["Auto-advances on a fixed step", "Head, body, and food render separately", "Exercises event polling and entity lookup"], - start: startSnakeExample - }, - { - id: "space-invaders", - label: "Space Invaders", - eyebrow: "Arcade loop with headless Matter", - title: "Original Space Invaders flow, adapted to typed ritmo systems.", - description: - "This keeps the original player movement, bullet spawning, enemy descent patterns, and collision-driven despawn flow, but ports the world logic to schema-bound ritmo systems and renders it inside the shared browser host.", - meta: ["Arrow keys move", "Space fires with cooldown", "Matter bodies stay headless behind Pixi"], - start: startSpaceInvadersExample - } -] - -const exampleMap = new Map(examples.map((example) => [example.id, example] as const)) - -const controls = document.querySelector("[data-example-controls]") -const mount = document.querySelector("[data-example-root]") -const eyebrow = document.querySelector("[data-example-eyebrow]") -const title = document.querySelector("[data-example-title]") -const description = document.querySelector("[data-example-description]") -const meta = document.querySelector("[data-example-meta]") - -if (!controls || !mount || !eyebrow || !title || !description || !meta) { - throw new Error("Missing example host elements in examples/pixi/index.html") -} - -let currentHandle: BrowserExampleHandle | null = null -let currentId: ExampleId | null = null -let switchToken = 0 - -const renderChrome = (example: ExampleDefinition): void => { - eyebrow.textContent = example.eyebrow - title.textContent = example.title - description.textContent = example.description - meta.replaceChildren( - ...example.meta.map((item) => { - const chip = document.createElement("span") - chip.textContent = item - return chip - }) - ) -} - -const renderMenu = (): void => { - controls.replaceChildren( - ...examples.map((example) => { - const button = document.createElement("button") - button.type = "button" - button.className = "example-switcher__button" - button.dataset.active = String(example.id === currentId) - button.textContent = example.label - button.addEventListener("click", () => { - void activateExample(example.id) - }) - return button - }) - ) -} - -const activateExample = async (id: ExampleId): Promise => { - const example = exampleMap.get(id) - if (!example) { - return - } - - const token = ++switchToken - - if (currentHandle) { - const previous = currentHandle - currentHandle = null - await previous.destroy() - } - - if (token !== switchToken) { - return - } - - currentId = id - renderChrome(example) - renderMenu() - currentHandle = await example.start(mount) - - if (token !== switchToken && currentHandle) { - const stale = currentHandle - currentHandle = null - await stale.destroy() - } -} - -void activateExample("pixi") diff --git a/examples/pixi/index.html b/examples/pixi/index.html deleted file mode 100644 index 54029f3..0000000 --- a/examples/pixi/index.html +++ /dev/null @@ -1,188 +0,0 @@ - - - - - - ritmo Browser Examples - - - -
-
-

PixiJS loop, ritmo ECS

-

External runtime, typed game logic.

-

- Pixi owns the canvas and ticker. The ECS side stays in ritmo: descriptors, - schema, systems, resources, commands, schedules, and service wiring all live in the example source. -

-
- - - -
-
-
-
-
- - - - diff --git a/examples/pixi/main.ts b/examples/pixi/main.ts deleted file mode 100644 index 2919ea5..0000000 --- a/examples/pixi/main.ts +++ /dev/null @@ -1,328 +0,0 @@ -import { Application, Container, Sprite, Texture } from "pixi.js"; - -import { Descriptor, Schema } from "@typeonce/ritmo"; -import { NodeRegistry, RenderSync } from "@typeonce/ritmo-render"; -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi"; - -export interface BrowserExampleHandle { - destroy(): Promise -} - -// ECS-owned simulation data. -const Position = Descriptor.Component<{ x: number; y: number }>()( - "Pixi/Position", -); -const Velocity = Descriptor.Component<{ x: number; y: number }>()( - "Pixi/Velocity", -); -const Renderable = Descriptor.Component<{ size: number }>()("Pixi/Renderable"); -const Tint = Descriptor.Component<{ value: number }>()("Pixi/Tint"); - -// Per-frame world data captured from the host renderer. -const DeltaTime = Descriptor.Resource()("Pixi/DeltaTime"); -const Viewport = Descriptor.Resource<{ width: number; height: number }>()( - "Pixi/Viewport", -); - -// Host-side renderer bridge. ECS owns simulation state; Pixi owns renderer objects. -const PixiHost = Descriptor.Service<{ - readonly application: Application; - readonly scene: Container; - readonly clock: { - deltaSeconds: number; - }; -}>()("Pixi/PixiHost"); -const Sprites = Descriptor.Service>()("Pixi/Sprites"); - -const pixiSchema = Schema.fragment({ - components: { - Position, - Velocity, - Renderable, - Tint, - }, - resources: { - DeltaTime, - Viewport, - }, -}); - -const Game = Schema.bind(pixiSchema); -const schema = Game.schema; - -const SetupSceneSystem = Game.System( - "Pixi/SetupSceneSystem", - { - services: { - pixi: Game.System.service(PixiHost), - }, - commands: Game.System.commands(Game.System.spawns(Position, Velocity, Renderable, Tint)), - }, - ({ commands, services }) => - { - const { width, height } = services.pixi.application.screen; - const palette = [ - 0xff6b35, 0xf7c948, 0x4ecdc4, 0x2d6cdf, 0xf25f5c, 0x7bd389, - ] as const; - - for (let index = 0; index < 12; index += 1) { - const angle = (Math.PI * 2 * index) / 12; - const speed = 70 + index * 8; - const size = 18 + (index % 4) * 6; - const tint = palette[index % palette.length] ?? palette[0]; - - commands.spawn( - Game.Command.spawn( - [ - Position, - { - x: width * 0.5 + Math.cos(angle) * 140, - y: height * 0.5 + Math.sin(angle) * 90, - }, - ], - [ - Velocity, - { - x: Math.cos(angle) * speed, - y: Math.sin(angle) * speed, - }, - ], - [ - Renderable, - { - size, - }, - ], - [ - Tint, - { - value: tint, - }, - ], - ), - ); - } - }, -); - -const CaptureFrameInputSystem = Game.System( - "Pixi/CaptureFrameInputSystem", - { - resources: { - deltaTime: Game.System.writeResource(DeltaTime), - viewport: Game.System.writeResource(Viewport), - }, - services: { - pixi: Game.System.service(PixiHost), - }, - }, - ({ resources, services }) => - { - resources.deltaTime.set(services.pixi.clock.deltaSeconds); - resources.viewport.set({ - width: services.pixi.application.screen.width, - height: services.pixi.application.screen.height, - }); - }, -); - -const IntegrateMotionSystem = Game.System( - "Pixi/IntegrateMotionSystem", - { - queries: { - moving: Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.read(Velocity), - }, - }), - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime), - }, - }, - ({ queries, resources }) => - { - const dt = resources.deltaTime.get(); - for (const match of queries.moving.each()) { - const position = match.data.position.get(); - const velocity = match.data.velocity.get(); - - match.data.position.set({ - x: position.x + velocity.x * dt, - y: position.y + velocity.y * dt, - }); - } - }, -); - -const BounceWithinViewportSystem = Game.System( - "Pixi/BounceWithinViewportSystem", - { - queries: { - moving: Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.write(Velocity), - renderable: Game.Query.read(Renderable), - }, - }), - }, - resources: { - viewport: Game.System.readResource(Viewport), - }, - }, - ({ queries, resources }) => - { - const viewport = resources.viewport.get(); - for (const match of queries.moving.each()) { - const position = match.data.position.get(); - const velocity = match.data.velocity.get(); - const { size } = match.data.renderable.get(); - - let nextPosition = position; - let nextVelocity = velocity; - - if ( - position.x <= size * 0.5 || - position.x >= viewport.width - size * 0.5 - ) { - nextVelocity = { - x: velocity.x * -1, - y: velocity.y, - }; - nextPosition = { - x: Math.min( - Math.max(position.x, size * 0.5), - viewport.width - size * 0.5, - ), - y: position.y, - }; - } - - if ( - position.y <= size * 0.5 || - position.y >= viewport.height - size * 0.5 - ) { - nextVelocity = { - x: nextVelocity.x, - y: velocity.y * -1, - }; - nextPosition = { - x: nextPosition.x, - y: Math.min( - Math.max(position.y, size * 0.5), - viewport.height - size * 0.5, - ), - }; - } - - if (nextPosition !== position) { - match.data.position.set(nextPosition); - } - - if (nextVelocity !== velocity) { - match.data.velocity.set(nextVelocity); - } - } - }, -); - -// Pixi owns the sprites; RenderSync keeps one per Renderable entity in sync. -const render = RenderSync.system(Game, { - name: "Pixi/Render", - renderable: Renderable, - transform: Position, - registry: Sprites, - select: { tint: Game.Query.read(Tint) }, - create: ({ renderable, data }) => { - const sprite = new Sprite(Texture.WHITE); - sprite.anchor.set(0.5); - sprite.width = renderable.size; - sprite.height = renderable.size; - sprite.tint = data.tint.get().value; - return sprite; - }, - apply: (sprite, { transform }) => { - sprite.position.set(transform.x, transform.y); - }, -}); - -const setupSchedule = Game.Schedule( - SetupSceneSystem, - Game.Schedule.applyDeferred(), - render, -); - -const updateSchedule = Game.Schedule( - CaptureFrameInputSystem, - IntegrateMotionSystem, - BounceWithinViewportSystem, - render, -); - -/** - * Starts the bouncing Pixi integration demo inside a host container. - */ -export const startPixiExample = async ( - mount: HTMLElement, -): Promise => { - const application = new Application(); - await application.init({ - antialias: true, - background: "#101418", - resizeTo: mount, - }); - - const wrapper = document.createElement("section"); - wrapper.className = "pixi-example-shell"; - wrapper.appendChild(application.canvas); - mount.replaceChildren(wrapper); - - const scene = new Container(); - application.stage.addChild(scene); - - const sprites = PixiNodeRegistry.make(scene); - const host = { - application, - scene, - clock: { - deltaSeconds: 1 / 60, - }, - }; - - const runtime = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(PixiHost, host), - Game.Runtime.service(Sprites, sprites), - ), - resources: { - DeltaTime: host.clock.deltaSeconds, - Viewport: { - width: application.screen.width, - height: application.screen.height, - }, - }, - }).value; - const setup = runtime.tick(setupSchedule); - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`); - const first = runtime.tick(updateSchedule); - if (!first.ok) console.error("update failed", first.error); - - const tick = (ticker: { readonly deltaMS: number }) => { - host.clock.deltaSeconds = ticker.deltaMS / 1000; - const ran = runtime.tick(updateSchedule); - if (!ran.ok) console.error("update failed", ran.error); - }; - - application.ticker.add(tick); - - return { - async destroy() { - application.ticker.remove(tick); - sprites.clear(); - application.destroy(true); - mount.replaceChildren(); - }, - }; -}; diff --git a/examples/pixi/package.json b/examples/pixi/package.json deleted file mode 100644 index 9a0bb59..0000000 --- a/examples/pixi/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@typeonce/ritmo-example-pixi", - "private": true, - "type": "module", - "dependencies": { - "@typeonce/ritmo": "workspace:*", - "@typeonce/ritmo-browser": "workspace:*", - "@typeonce/ritmo-pixi": "workspace:*", - "@typeonce/ritmo-render": "workspace:*", - "@typeonce/ritmo-math": "workspace:*" - } -} diff --git a/examples/pixi/pokemon.ts b/examples/pixi/pokemon.ts deleted file mode 100644 index 93a7a29..0000000 --- a/examples/pixi/pokemon.ts +++ /dev/null @@ -1,548 +0,0 @@ -/** - * Minimal `pokemon`-style example with ordered tile movement. - * - * ECS owns gameplay state: - * - tile coordinates - * - movement intent and interpolation state - * - collision decisions - * - * Pixi owns renderer objects and the outer frame loop. - */ -import { Application, Container, Graphics } from "pixi.js" - -import { Keyboard } from "@typeonce/ritmo-browser" -import { Descriptor, Schema } from "@typeonce/ritmo" -import { NodeRegistry, RenderSync } from "@typeonce/ritmo-render" -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi" - -interface BrowserExampleHandle { - destroy(): Promise -} - -const Root = Schema.defineRoot("Pokemon") - -const GRID_COLS = 10 -const GRID_ROWS = 10 -const TILE_SIZE = 32 -const MOVE_DURATION_SECONDS = 0.18 - -type Direction = "up" | "down" | "left" | "right" -type TilePosition = { col: number; row: number } - -const Position = Descriptor.Component()("Pokemon/Position") -const Movement = Descriptor.Component<{ - direction: Direction | null - from: TilePosition - to: TilePosition - progress: number - isMoving: boolean -}>()("Pokemon/Movement") -const Renderable = Descriptor.Component<{ - kind: "player" | "solid" -}>()("Pokemon/Renderable") -const Player = Descriptor.Component<{}>()("Pokemon/Player") -const Solid = Descriptor.Component<{}>()("Pokemon/Solid") - -const GridSize = Descriptor.Resource<{ cols: number; rows: number; tileSize: number }>()("Pokemon/GridSize") -const DeltaTime = Descriptor.Resource()("Pokemon/DeltaTime") - -const InputManager = Descriptor.Service<{ - readonly direction: () => Direction | null -}>()("Pokemon/InputManager") - -const PixiHost = Descriptor.Service<{ - readonly application: Application - readonly scene: Container - readonly clock: { - deltaSeconds: number - } -}>()("Pokemon/PixiHost") -const PokemonNodes = Descriptor.Service>()("Pokemon/Nodes") - -const Game = Schema.bind( - Schema.fragment({ - components: { - Position, - Movement, - Renderable, - Player, - Solid - }, - requires: [ - Schema.requires(Player, Position, Movement, Renderable), - Schema.requires(Solid, Position, Renderable) - ], - resources: { - GridSize, - DeltaTime - } - }), - Root -) -const schema = Game.schema - -const PlayerQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - movement: Game.Query.write(Movement), - player: Game.Query.read(Player) - } -}) - -const SolidQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - solid: Game.Query.read(Solid) - } -}) - -const makePokemonNode = (kind: "player" | "solid", tileSize: number): Graphics => { - const node = new Graphics() - if (kind === "player") { - node.roundRect(0, 0, tileSize - 10, tileSize - 10, 10) - node.fill(0xff8f3f) - node.stroke({ - color: 0xffd2a8, - width: 2 - }) - return node - } - - node.roundRect(0, 0, tileSize - 8, tileSize - 8, 6) - node.fill(0x2fb7c8) - node.stroke({ - color: 0xa7f1ff, - width: 2 - }) - return node -} - -const nextTileFromDirection = (position: TilePosition, direction: Direction): TilePosition => - direction === "up" ? { col: position.col, row: position.row - 1 } - : direction === "down" ? { col: position.col, row: position.row + 1 } - : direction === "left" ? { col: position.col - 1, row: position.row } - : { col: position.col + 1, row: position.row } - -const setNodeTilePosition = ( - node: Graphics, - kind: "player" | "solid", - tileSize: number, - x: number, - y: number -): void => { - const inset = kind === "player" ? 5 : 4 - node.position.set(x + inset, y + inset) -} - -const INITIAL_PLAYER_POSITION = { col: 3, row: 3 } as const - -const makePlayerDraft = () => - Game.Command.spawn( - [Position, INITIAL_PLAYER_POSITION], - [Movement, { - direction: null, - from: INITIAL_PLAYER_POSITION, - to: INITIAL_PLAYER_POSITION, - progress: 1, - isMoving: false - }], - [Renderable, { - kind: "player" - }], - [Player, {}] - ) - -const makeSolidDraft = (position: TilePosition) => - Game.Command.spawn( - [Position, position], - [Renderable, { - kind: "solid" - }], - [Solid, {}] - ) - -const SetupSystem = Game.System( - "Pokemon/Setup", - { - commands: Game.System.commands(Game.System.spawns(Player, Solid)) - }, - ({ commands }) => - { - commands.spawn(makePlayerDraft()) - - const solids = [ - { col: 5, row: 5 }, - { col: 2, row: 6 }, - { col: 7, row: 3 }, - { col: 6, row: 7 } - ] as const - - for (const solid of solids) { - commands.spawn(makeSolidDraft(solid)) - } - } -) - -const CaptureFrameInputSystem = Game.System( - "Pokemon/CaptureFrameInput", - { - resources: { - deltaTime: Game.System.writeResource(DeltaTime) - }, - services: { - pixi: Game.System.service(PixiHost) - } - }, - ({ resources, services }) => - { - resources.deltaTime.set(services.pixi.clock.deltaSeconds) - } -) - -const InputSystem = Game.System( - "Pokemon/Input", - { - queries: { - player: PlayerQuery - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ queries, services }) => - { - const direction = services.input.direction() - if (direction === null) { - return - } - - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - player.value.data.movement.update((movement) => - movement.isMoving - ? movement - : { - ...movement, - direction - } - ) - } -) - -const PlanMovementSystem = Game.System( - "Pokemon/PlanMovement", - { - queries: { - player: PlayerQuery - } - }, - ({ queries }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const match = player.value - const position = match.data.position.get() - const movement = match.data.movement.get() - if (movement.isMoving || movement.direction === null) { - return - } - - match.data.movement.set({ - ...movement, - from: position, - to: nextTileFromDirection(position, movement.direction), - progress: 0, - isMoving: true - }) - } -) - -const CollisionSystem = Game.System( - "Pokemon/Collision", - { - queries: { - player: PlayerQuery, - solids: SolidQuery - }, - resources: { - grid: Game.System.readResource(GridSize) - } - }, - ({ queries, resources }) => - { - const { cols, rows } = resources.grid.get() - const occupied = new Set( - queries.solids.each().map((match) => { - const position = match.data.position.get() - return `${position.col},${position.row}` - }) - ) - - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const match = player.value - const movement = match.data.movement.get() - if (!movement.isMoving) { - return - } - - const outOfBounds = - movement.to.col < 0 || - movement.to.row < 0 || - movement.to.col >= cols || - movement.to.row >= rows - - if (outOfBounds || occupied.has(`${movement.to.col},${movement.to.row}`)) { - match.data.movement.set({ - ...movement, - direction: null, - to: movement.from, - progress: 1, - isMoving: false - }) - } - } -) - -const AdvanceMovementSystem = Game.System( - "Pokemon/AdvanceMovement", - { - queries: { - player: PlayerQuery - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime) - } - }, - ({ queries, resources }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const match = player.value - const movement = match.data.movement.get() - if (!movement.isMoving) { - return - } - - const nextProgress = Math.min( - movement.progress + resources.deltaTime.get() / MOVE_DURATION_SECONDS, - 1 - ) - - if (nextProgress >= 1) { - match.data.position.set(movement.to) - match.data.movement.set({ - direction: null, - from: movement.to, - to: movement.to, - progress: 1, - isMoving: false - }) - return - } - - match.data.movement.set({ - ...movement, - progress: nextProgress - }) - } -) - -// Moving entities are drawn between their `from` and `to` tiles, so the -// node is re-applied whenever `Movement` advances, not only when the tile -// position changes. -const render = RenderSync.system(Game, { - name: "Pokemon/Render", - renderable: Renderable, - transform: Position, - registry: PokemonNodes, - select: { movement: Game.Query.optional(Movement) }, - resources: { grid: GridSize }, - redrawOn: [Movement], - create: ({ renderable, resources }) => makePokemonNode(renderable.kind, resources.grid.get().tileSize), - apply: (node, { renderable, transform, data, resources }) => { - const { tileSize } = resources.grid.get() - if (!data.movement.present) { - setNodeTilePosition(node, renderable.kind, tileSize, transform.col * tileSize, transform.row * tileSize) - return - } - const { from, to, progress } = data.movement.get() - setNodeTilePosition( - node, - renderable.kind, - tileSize, - (from.col + (to.col - from.col) * progress) * tileSize, - (from.row + (to.row - from.row) * progress) * tileSize - ) - } -}) - -const setupSchedule = Game.Schedule(SetupSystem) - -const browserSetupSchedule = Game.Schedule( - setupSchedule, - Game.Schedule.applyDeferred(), - render -) - -const updateSchedule = Game.Schedule( - InputSystem, - PlanMovementSystem, - CollisionSystem, - AdvanceMovementSystem -) - -const browserUpdateSchedule = Game.Schedule( - CaptureFrameInputSystem, - updateSchedule, - Game.Schedule.applyDeferred(), - render -) - -export const createPokemonExample = (input: { - readonly direction: () => Direction | null -}) => { - const runtime = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(InputManager, input) - ), - resources: { - GridSize: { - cols: GRID_COLS, - rows: GRID_ROWS, - tileSize: TILE_SIZE - }, - DeltaTime: 1 / 60 - } - }).value - const setup = runtime.tick(setupSchedule) - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`) - - return { - runtime, - update() { - const ran = runtime.tick(updateSchedule) - if (!ran.ok) console.error("update failed", ran.error) - } - } -} - -const renderGrid = (cols: number, rows: number, tileSize: number): Graphics => { - const width = cols * tileSize - const height = rows * tileSize - const grid = new Graphics() - grid.zIndex = 0 - - grid.roundRect(0, 0, width, height, 18) - grid.fill(0x101418) - - for (let x = 0; x <= width; x += tileSize) { - grid.moveTo(x, 0) - grid.lineTo(x, height) - } - for (let y = 0; y <= height; y += tileSize) { - grid.moveTo(0, y) - grid.lineTo(width, y) - } - grid.stroke({ - color: 0x24303b, - width: 1 - }) - - return grid -} - -export const startPokemonExample = async (mount: HTMLElement): Promise => { - const application = new Application() - await application.init({ - antialias: false, - background: "#0c1015", - width: GRID_COLS * TILE_SIZE, - height: GRID_ROWS * TILE_SIZE - }) - - const wrapper = document.createElement("section") - wrapper.className = "pixi-example-shell pixi-example-shell-grid" - wrapper.appendChild(application.canvas) - mount.replaceChildren(wrapper) - - const scene = new Container() - scene.zIndex = 10 - application.stage.sortableChildren = true - application.stage.addChild(renderGrid(GRID_COLS, GRID_ROWS, TILE_SIZE)) - application.stage.addChild(scene) - - const keyboard = Keyboard.actions(window, { - up: ["ArrowUp", "w"], - down: ["ArrowDown", "s"], - left: ["ArrowLeft", "a"], - right: ["ArrowRight", "d"] - }) - const directions = ["up", "down", "left", "right"] as const - const nodes = PixiNodeRegistry.make(scene) - - const host = { - application, - scene, - clock: { - deltaSeconds: 1 / 60 - } - } - - const runtime = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(InputManager, { - // Holding a direction keeps walking (movement only accepts a new - // direction once the current step finishes); a quick tap still counts. - direction() { - const snapshot = keyboard.snapshot() - return directions.find((direction) => snapshot[direction].held || snapshot[direction].pressed) ?? null - } - }), - Game.Runtime.service(PixiHost, host), - Game.Runtime.service(PokemonNodes, nodes) - ), - resources: { - GridSize: { - cols: GRID_COLS, - rows: GRID_ROWS, - tileSize: TILE_SIZE - }, - DeltaTime: host.clock.deltaSeconds - } - }).value - const setup = runtime.tick(browserSetupSchedule) - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`) - const first = runtime.tick(browserUpdateSchedule) - if (!first.ok) console.error("update failed", first.error) - - const tick = (ticker: { readonly deltaMS: number }) => { - host.clock.deltaSeconds = Math.min(ticker.deltaMS / 1000, 0.05) - const ran = runtime.tick(browserUpdateSchedule) - if (!ran.ok) console.error("update failed", ran.error) - } - - application.ticker.add(tick) - - return { - async destroy() { - application.ticker.remove(tick) - keyboard.dispose() - nodes.clear() - application.destroy(true) - mount.replaceChildren() - } - } -} diff --git a/examples/pixi/snake.ts b/examples/pixi/snake.ts deleted file mode 100644 index 930d742..0000000 --- a/examples/pixi/snake.ts +++ /dev/null @@ -1,948 +0,0 @@ -import { Application, Container, Graphics } from "pixi.js" - -import { Keyboard } from "@typeonce/ritmo-browser" -import { Descriptor, Entity, Schema } from "@typeonce/ritmo" -import { NodeRegistry, RenderSync } from "@typeonce/ritmo-render" -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi" - -interface BrowserExampleHandle { - destroy(): Promise -} - -const Root = Schema.defineRoot("Snake") - -type GridPosition = { - x: number - y: number -} - -type GameOverReasonValue = "Collision" | "Victory" | null - -type SnakeHud = { - readonly root: HTMLDivElement - readonly score: HTMLSpanElement - readonly state: HTMLSpanElement - readonly scrim: HTMLDivElement - readonly overlay: HTMLDivElement - readonly title: HTMLHeadingElement - readonly subtitle: HTMLParagraphElement - readonly footer: HTMLParagraphElement -} - -const Position = Descriptor.Component()("Snake/Position") -const PreviousPosition = Descriptor.Component()("Snake/PreviousPosition") -const Velocity = Descriptor.Component()("Snake/Velocity") -const SnakeHead = Descriptor.Component<{}>()("Snake/Head") -const SnakeBody = Descriptor.Component<{ isTail: boolean }>()("Snake/Body") -const Food = Descriptor.Component<{}>()("Snake/Food") -const { relation: ChildOf } = Descriptor.Hierarchy("Snake/ChildOf", "Snake/Children") - -const FoodEaten = Descriptor.Event<{ entity: Entity.Handle }>()("Snake/FoodEaten") - -const Score = Descriptor.Resource()("Snake/Score") -const PendingGrowth = Descriptor.Resource()("Snake/PendingGrowth") -const SpawnSeed = Descriptor.Resource()("Snake/SpawnSeed") -const GameOverReason = Descriptor.Resource()("Snake/GameOverReason") - -const InputManager = Descriptor.Service<{ - readonly consumeDirection: () => GridPosition | null - readonly consumeRestart: () => boolean -}>()("Snake/InputManager") - -const PixiHost = Descriptor.Service<{ - readonly scene: Container - readonly tileSize: number - readonly ui: SnakeHud -}>()("Snake/PixiHost") -const SnakeNodes = Descriptor.Service>()("Snake/Nodes") - -const SNAKE_BOARD_WIDTH = 12 -const SNAKE_BOARD_HEIGHT = 12 -const INITIAL_HEAD_POSITION: GridPosition = { x: 5, y: 5 } -const INITIAL_VELOCITY: GridPosition = { x: 1, y: 0 } -const INITIAL_TAIL_POSITION: GridPosition = { x: 4, y: 5 } - -const Game = Schema.bind( - Schema.fragment({ - components: { - Position, - PreviousPosition, - Velocity, - SnakeHead, - SnakeBody, - Food - }, - requires: [ - Schema.requires(SnakeHead, Position, PreviousPosition, Velocity), - Schema.requires(SnakeBody, Position, PreviousPosition), - Schema.requires(Food, Position, PreviousPosition) - ], - resources: { - Score, - PendingGrowth, - SpawnSeed, - GameOverReason - }, - events: { - FoodEaten - }, - relations: { - ChildOf - } - }), - Root -) -const schema = Game.schema -const GamePhase = Game.StateMachine("Snake/Phase", ["Playing", "GameOver"]) - -// One round's head and food. Body segments are ChildOf the head, so they go -// with it. Entering Playing (a restart) despawns the round, before -// ResetGameSystem spawns the next one. -const RoundScope = Game.EntityScope("Snake/Round", { despawnOnEnter: [GamePhase, "Playing"] }) - -const HeadQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - previousPosition: Game.Query.write(PreviousPosition), - velocity: Game.Query.write(Velocity), - head: Game.Query.read(SnakeHead) - } -}) - -const BodyQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - previousPosition: Game.Query.write(PreviousPosition), - body: Game.Query.write(SnakeBody) - } -}) - -const FoodQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - food: Game.Query.read(Food) - } -}) - -const OccupiedCellQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - head: Game.Query.optional(SnakeHead), - body: Game.Query.optional(SnakeBody), - food: Game.Query.optional(Food) - } -}) - -const sameCell = (left: GridPosition, right: GridPosition): boolean => - left.x === right.x && left.y === right.y - -const wrapPosition = (position: GridPosition): GridPosition => ({ - x: (position.x + SNAKE_BOARD_WIDTH) % SNAKE_BOARD_WIDTH, - y: (position.y + SNAKE_BOARD_HEIGHT) % SNAKE_BOARD_HEIGHT -}) - -const nextSeed = (seed: number): number => - (Math.imul(seed, 1664525) + 1013904223) >>> 0 - -const randomSeedFromNow = (): number => { - const seed = Date.now() >>> 0 - return seed === 0 ? 1 : seed -} - -const makeHeadDraft = () => - Game.Command.spawn( - [Position, INITIAL_HEAD_POSITION], - [PreviousPosition, INITIAL_HEAD_POSITION], - [Velocity, INITIAL_VELOCITY], - [SnakeHead, {}] - ) - -const makeTailDraft = ( - parent: Entity.EntityId, - position: GridPosition -) => - Game.Command.relate( - Game.Command.spawn( - [Position, position], - [PreviousPosition, position], - [SnakeBody, { - isTail: true - }] - ), - ChildOf, - parent - ) - -const makeFoodDraft = (position: GridPosition) => - Game.Command.spawn( - [Position, position], - [PreviousPosition, position], - [Food, {}] - ) - -const makeHud = (): SnakeHud => { - const root = document.createElement("div") - root.style.position = "absolute" - root.style.inset = "0" - root.style.display = "grid" - root.style.gridTemplateRows = "auto 1fr" - root.style.pointerEvents = "none" - - const topBar = document.createElement("div") - topBar.style.display = "flex" - topBar.style.justifyContent = "space-between" - topBar.style.gap = "12px" - topBar.style.padding = "14px 16px 0" - - const makePill = (): HTMLSpanElement => { - const node = document.createElement("span") - node.style.display = "inline-flex" - node.style.alignItems = "center" - node.style.padding = "8px 12px" - node.style.borderRadius = "999px" - node.style.background = "rgba(6, 10, 14, 0.78)" - node.style.border = "1px solid rgba(255,255,255,0.08)" - node.style.color = "#f2f5f7" - node.style.fontFamily = "\"IBM Plex Mono\", monospace" - node.style.fontSize = "12px" - node.style.letterSpacing = "0.08em" - node.style.textTransform = "uppercase" - return node - } - - const score = makePill() - const state = makePill() - topBar.append(score, state) - - const center = document.createElement("div") - center.style.position = "relative" - center.style.display = "grid" - center.style.placeItems = "center" - center.style.padding = "20px" - - const scrim = document.createElement("div") - scrim.style.position = "absolute" - scrim.style.inset = "0" - scrim.style.background = "linear-gradient(180deg, rgba(4, 7, 10, 0.1), rgba(4, 7, 10, 0.72))" - scrim.style.opacity = "0" - scrim.style.transition = "opacity 120ms ease" - - const overlay = document.createElement("div") - overlay.style.position = "relative" - overlay.style.display = "grid" - overlay.style.gap = "10px" - overlay.style.justifyItems = "center" - overlay.style.maxWidth = "320px" - overlay.style.padding = "18px 22px" - overlay.style.borderRadius = "22px" - overlay.style.background = "rgba(7, 11, 15, 0.72)" - overlay.style.border = "1px solid rgba(255,255,255,0.08)" - overlay.style.backdropFilter = "blur(8px)" - overlay.style.opacity = "0" - overlay.style.transition = "opacity 120ms ease" - - const title = document.createElement("h2") - title.style.margin = "0" - title.style.fontSize = "36px" - title.style.lineHeight = "0.95" - title.style.letterSpacing = "-0.04em" - - const subtitle = document.createElement("p") - subtitle.style.margin = "0" - subtitle.style.color = "#bfd0dc" - subtitle.style.fontSize = "14px" - subtitle.style.lineHeight = "1.5" - subtitle.style.textAlign = "center" - - const footer = document.createElement("p") - footer.style.margin = "0" - footer.style.color = "#84e0d2" - footer.style.fontFamily = "\"IBM Plex Mono\", monospace" - footer.style.fontSize = "12px" - footer.style.letterSpacing = "0.05em" - footer.style.textTransform = "uppercase" - footer.style.textAlign = "center" - - overlay.append(title, subtitle, footer) - center.append(scrim, overlay) - root.append(topBar, center) - - return { root, score, state, scrim, overlay, title, subtitle, footer } -} - -const makeSnakeNode = (kind: "head" | "body" | "food", tileSize: number): Graphics => { - const node = new Graphics() - if (kind === "food") { - node.circle(tileSize * 0.5, tileSize * 0.5, tileSize * 0.28) - node.fill(0xff7a59) - node.stroke({ - color: 0xffcfbf, - width: 2 - }) - return node - } - - const inset = kind === "head" ? 3 : 5 - const size = tileSize - inset * 2 - node.roundRect(0, 0, size, size, kind === "head" ? 9 : 6) - node.fill(kind === "head" ? 0xf7c948 : 0x4ecdc4) - node.stroke({ - color: kind === "head" ? 0xffefb8 : 0xbef6ef, - width: 2 - }) - return node -} - -const renderBoard = (cellsWide: number, cellsHigh: number, tileSize: number): Graphics => { - const width = cellsWide * tileSize - const height = cellsHigh * tileSize - const board = new Graphics() - board.zIndex = 0 - - board.roundRect(0, 0, width, height, 18) - board.fill(0x101418) - - for (let x = 0; x <= width; x += tileSize) { - board.moveTo(x, 0) - board.lineTo(x, height) - } - for (let y = 0; y <= height; y += tileSize) { - board.moveTo(0, y) - board.lineTo(width, y) - } - board.stroke({ - color: 0x24303b, - width: 1 - }) - - return board -} - -// Every positioned entity is a head, a body segment, or food. -const snakeNodeKind = (data: { - readonly head: { readonly present: boolean } - readonly body: { readonly present: boolean } -}): "head" | "body" | "food" => - data.head.present ? "head" : data.body.present ? "body" : "food" - -const placeNode = ( - node: Graphics, - kind: "head" | "body" | "food", - tileSize: number, - position: GridPosition -): void => { - if (kind === "food") { - node.position.set(position.x * tileSize, position.y * tileSize) - return - } - - const inset = kind === "head" ? 3 : 5 - node.position.set( - position.x * tileSize + inset, - position.y * tileSize + inset - ) -} - -const ResetGameSystem = Game.System( - "Snake/ResetGame", - { - resources: { - score: Game.System.writeResource(Score), - pendingGrowth: Game.System.writeResource(PendingGrowth), - reason: Game.System.writeResource(GameOverReason) - }, - commands: Game.System.commands(Game.System.spawns(SnakeHead, SnakeBody), Game.System.relates(ChildOf)) - }, - ({ resources, commands }) => - { - resources.score.set(0) - resources.pendingGrowth.set(0) - resources.reason.set(null) - - // The previous round was despawned with RoundScope on entering Playing. - const headId = commands.spawnIn(RoundScope, makeHeadDraft()) - - commands.spawn( - makeTailDraft(headId, INITIAL_TAIL_POSITION) - ) - } -) - -const QueueRestartSystem = Game.System( - "Snake/QueueRestart", - { - when: [Game.Condition.inState(GamePhase, "GameOver")], - nextMachines: { - phase: Game.System.nextState(GamePhase) - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ nextMachines, services }) => - { - if (services.input.consumeRestart()) { - // Restart is queued here and becomes real only at applyStateTransitions(). - nextMachines.phase.set("Playing") - } - } -) - -const CapturePreviousPositionsSystem = Game.System( - "Snake/CapturePreviousPositions", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - head: HeadQuery, - body: BodyQuery - } - }, - ({ queries }) => - { - const head = queries.head.singleOptional() - if (!head.ok || !head.value) { - return - } - - const headPosition = head.value.data.position.get() - head.value.data.previousPosition.set(headPosition) - - for (const match of queries.body.each()) { - match.data.previousPosition.set(match.data.position.get()) - } - } -) - -const BrowserInputSystem = Game.System( - "Snake/BrowserInput", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - head: HeadQuery - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ queries, services }) => - { - const next = services.input.consumeDirection() - if (!next) { - return - } - - const head = queries.head.singleOptional() - if (!head.ok || !head.value) { - return - } - - const current = head.value.data.velocity.get() - const reversing = current.x === next.x * -1 && current.y === next.y * -1 - if (reversing) { - return - } - - head.value.data.velocity.set(next) - } -) - -const MoveHeadSystem = Game.System( - "Snake/MoveHead", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - head: HeadQuery - } - }, - ({ queries }) => - { - const head = queries.head.singleOptional() - if (!head.ok || !head.value) { - return - } - - const position = head.value.data.position.get() - const velocity = head.value.data.velocity.get() - head.value.data.position.set(wrapPosition({ - x: position.x + velocity.x, - y: position.y + velocity.y - })) - } -) - -const MoveBodySystem = Game.System( - "Snake/MoveBody", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - head: HeadQuery, - body: BodyQuery - } - }, - ({ queries }) => - { - const head = queries.head.singleOptional() - if (!head.ok || !head.value) { - return - } - - const body = queries.body.descendants(head.value.entity.id, ChildOf, { order: "depth" }) - if (!body.ok) { - return - } - - let previous = head.value.data.previousPosition.get() - for (const match of body.value) { - const nextPrevious = match.data.previousPosition.get() - match.data.position.set(previous) - previous = nextPrevious - } - } -) - -const DetectFoodCollisionSystem = Game.System( - "Snake/DetectFoodCollision", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - head: Game.Query({ - selection: { - position: Game.Query.read(Position), - head: Game.Query.read(SnakeHead) - } - }), - food: FoodQuery - }, - events: { - foodEaten: Game.System.writeEvent(FoodEaten) - } - }, - ({ queries, events }) => - { - const head = queries.head.singleOptional() - if (!head.ok || !head.value) { - return - } - - const headPosition = head.value.data.position.get() - for (const match of queries.food.each()) { - if (sameCell(headPosition, match.data.position.get())) { - // The event outlives this system's view of the entity, so store a - // durable handle now and re-resolve it in ResolveFoodEatenSystem. - events.foodEaten.emit({ - entity: Game.Entity.handle(match.entity.id, Food) - }) - } - } - } -) - -const ResolveFoodEatenSystem = Game.System( - "Snake/ResolveFoodEaten", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - events: { - foodEaten: Game.System.readEvent(FoodEaten) - }, - queries: { - food: FoodQuery - }, - resources: { - score: Game.System.writeResource(Score), - pendingGrowth: Game.System.writeResource(PendingGrowth) - }, - commands: Game.System.commands(Game.System.despawns(Food)) - }, - ({ queries, events, resources, commands }) => - { - for (const event of events.foodEaten.all()) { - // Event visibility is committed now, but the food entity may already be - // stale, so re-resolve the durable handle explicitly. - const food = queries.food.getHandle(event.entity) - if (!food.ok) { - continue - } - - commands.despawn(food.value.entity) - resources.score.update((score) => score + 1) - resources.pendingGrowth.update((growth) => growth + 1) - } - } -) - -const GrowSnakeSystem = Game.System( - "Snake/Grow", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - head: HeadQuery, - body: BodyQuery - }, - resources: { - pendingGrowth: Game.System.writeResource(PendingGrowth) - }, - commands: Game.System.commands(Game.System.spawns(SnakeBody), Game.System.relates(ChildOf)) - }, - ({ queries, resources, commands }) => - { - const pendingGrowth = resources.pendingGrowth.get() - if (pendingGrowth <= 0) { - return - } - - const tail = queries.body.each().find((match) => match.data.body.get().isTail) - if (tail) { - const tailPrevious = tail.data.previousPosition.get() - tail.data.body.update((body) => ({ - ...body, - isTail: false - })) - - commands.spawn( - makeTailDraft(tail.entity.id, tailPrevious) - ) - } else { - const head = queries.head.singleOptional() - if (!head.ok || !head.value) { - return - } - - const headPrevious = head.value.data.previousPosition.get() - commands.spawn( - makeTailDraft(head.value.entity.id, headPrevious) - ) - } - - resources.pendingGrowth.set(pendingGrowth - 1) - } -) - -const DetectSelfCollisionSystem = Game.System( - "Snake/DetectSelfCollision", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - head: Game.Query({ - selection: { - position: Game.Query.read(Position), - head: Game.Query.read(SnakeHead) - } - }), - body: Game.Query({ - selection: { - position: Game.Query.read(Position), - body: Game.Query.read(SnakeBody) - } - }) - }, - resources: { - reason: Game.System.writeResource(GameOverReason) - }, - nextMachines: { - phase: Game.System.nextState(GamePhase) - } - }, - ({ queries, resources, nextMachines }) => - { - const head = queries.head.singleOptional() - if (!head.ok || !head.value) { - return - } - - const headPosition = head.value.data.position.get() - for (const match of queries.body.each()) { - if (sameCell(headPosition, match.data.position.get())) { - resources.reason.set("Collision") - nextMachines.phase.set("GameOver") - return - } - } - } -) - -const EnsureFoodSystem = Game.System( - "Snake/EnsureFood", - { - when: [Game.Condition.inState(GamePhase, "Playing")], - queries: { - food: FoodQuery, - occupied: OccupiedCellQuery - }, - resources: { - seed: Game.System.writeResource(SpawnSeed), - reason: Game.System.writeResource(GameOverReason) - }, - nextMachines: { - phase: Game.System.nextState(GamePhase) - }, - commands: Game.System.commands(Game.System.spawns(Food)) - }, - ({ queries, resources, nextMachines, commands }) => - { - if (resources.reason.get() !== null) { - return - } - - if (queries.food.each().length > 0) { - return - } - - const occupied = new Set() - for (const match of queries.occupied.each()) { - occupied.add(`${match.data.position.get().x}:${match.data.position.get().y}`) - } - - const freeCells: Array = [] - for (let y = 0; y < SNAKE_BOARD_HEIGHT; y += 1) { - for (let x = 0; x < SNAKE_BOARD_WIDTH; x += 1) { - if (!occupied.has(`${x}:${y}`)) { - freeCells.push({ x, y }) - } - } - } - - if (freeCells.length === 0) { - resources.reason.set("Victory") - nextMachines.phase.set("GameOver") - return - } - - const seed = nextSeed(resources.seed.get()) - resources.seed.set(seed) - const spawnAt = freeCells[seed % freeCells.length] - if (!spawnAt) { - return - } - - commands.spawnIn(RoundScope, makeFoodDraft(spawnAt)) - } -) - -// Positions are both what makes an entity drawable and what moves it. -const render = RenderSync.system(Game, { - name: "Snake/Render", - renderable: Position, - transform: Position, - registry: SnakeNodes, - select: { - head: Game.Query.optional(SnakeHead), - body: Game.Query.optional(SnakeBody) - }, - services: { pixi: PixiHost }, - create: ({ data, services }) => makeSnakeNode(snakeNodeKind(data), services.pixi.tileSize), - apply: (node, { data, transform, services }) => placeNode(node, snakeNodeKind(data), services.pixi.tileSize, transform) -}) - -const SyncHudSystem = Game.System( - "Snake/SyncHud", - { - resources: { - score: Game.System.changedResource(Score), - reason: Game.System.changedResource(GameOverReason) - }, - machines: { - phase: Game.System.machine(GamePhase) - }, - transitionEvents: { - phase: Game.System.readTransitionEvent(GamePhase) - }, - services: { - pixi: Game.System.service(PixiHost) - } - }, - ({ resources, machines, transitionEvents, services }) => - { - // Rewrite the HUD only when the score, the reason, or the phase moved since the previous run. - if (!resources.score.changed() && !resources.reason.changed() && transitionEvents.phase.all().length === 0) return - const hud = services.pixi.ui - const score = resources.score.get() - const reason = resources.reason.get() - const phase = machines.phase.get() - - hud.score.textContent = `Score ${score}` - hud.state.textContent = phase === "Playing" ? "Playing" : "Game Over" - - if (phase === "Playing") { - hud.scrim.style.opacity = "0" - hud.overlay.style.opacity = "0" - hud.title.textContent = "Snake" - hud.subtitle.textContent = "Wrap the board, eat food, and avoid your own body." - hud.footer.textContent = "Arrows or WASD to steer" - return - } - - hud.scrim.style.opacity = "0.62" - hud.overlay.style.opacity = "1" - hud.title.textContent = reason === "Victory" ? "Board Cleared" : "Game Over" - hud.subtitle.textContent = reason === "Victory" - ? `You filled all ${SNAKE_BOARD_WIDTH * SNAKE_BOARD_HEIGHT} cells. Final score ${score}.` - : `The snake collided with itself. Final score ${score}.` - hud.footer.textContent = "Press Space or Enter to restart" - } -) - -const setupSchedule = Game.Schedule( - ResetGameSystem, - Game.Schedule.applyDeferred(), - EnsureFoodSystem, - Game.Schedule.applyDeferred() -) - -const browserSetupSchedule = Game.Schedule( - setupSchedule, - render, - SyncHudSystem -) - -const phaseTransitions = Game.Schedule.transitions( - Game.Schedule.onEnter(GamePhase, "Playing", [ - // Reset work stays explicit and runs only once the new phase is committed. - ResetGameSystem - ]) -) - -const updateSchedule = Game.Schedule( - CapturePreviousPositionsSystem, - MoveHeadSystem, - MoveBodySystem, - DetectFoodCollisionSystem, - ResolveFoodEatenSystem, - Game.Schedule.applyDeferred(), - GrowSnakeSystem, - Game.Schedule.applyDeferred(), - DetectSelfCollisionSystem, - EnsureFoodSystem, - Game.Schedule.applyDeferred(), - Game.Schedule.applyStateTransitions(phaseTransitions) -) - -const browserUpdateSchedule = Game.Schedule( - QueueRestartSystem, - BrowserInputSystem, - updateSchedule, - Game.Schedule.applyDeferred(), - render, - SyncHudSystem -) - -export const createSnakeExample = () => { - const runtime = Game.Runtime.make({ - services: Game.Runtime.services(), - resources: { - Score: 0, - PendingGrowth: 0, - SpawnSeed: 1, - GameOverReason: null - }, - machines: Game.Runtime.machines( - Game.Runtime.machine(GamePhase, "Playing") - ) - }).value - const setup = runtime.tick(setupSchedule) - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`) - - return { - runtime, - update() { - const ran = runtime.tick(updateSchedule) - if (!ran.ok) console.error("update failed", ran.error) - } - } -} - -export const startSnakeExample = async (mount: HTMLElement): Promise => { - const tileSize = 32 - const cellsWide = SNAKE_BOARD_WIDTH - const cellsHigh = SNAKE_BOARD_HEIGHT - - const application = new Application() - await application.init({ - antialias: false, - background: "#0c1015", - width: cellsWide * tileSize, - height: cellsHigh * tileSize - }) - - const wrapper = document.createElement("section") - wrapper.className = "pixi-example-shell pixi-example-shell-grid" - wrapper.style.position = "relative" - wrapper.style.overflow = "hidden" - wrapper.style.borderRadius = "24px" - wrapper.appendChild(application.canvas) - - const hud = makeHud() - wrapper.appendChild(hud.root) - mount.replaceChildren(wrapper) - - const scene = new Container() - scene.zIndex = 10 - application.stage.sortableChildren = true - application.stage.addChild(renderBoard(cellsWide, cellsHigh, tileSize)) - application.stage.addChild(scene) - - const keyboard = Keyboard.actions(window, { - up: ["ArrowUp", "w"], - down: ["ArrowDown", "s"], - left: ["ArrowLeft", "a"], - right: ["ArrowRight", "d"], - restart: [" ", "Enter"] - }) - const directions = [ - ["up", { x: 0, y: -1 }], - ["down", { x: 0, y: 1 }], - ["left", { x: -1, y: 0 }], - ["right", { x: 1, y: 0 }] - ] as const - // One snapshot per tick, so presses between ticks are never lost. - let input = keyboard.snapshot() - const nodes = PixiNodeRegistry.make(scene) - - const host = { - scene, - tileSize, - ui: hud - } - - const runtime = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(InputManager, { - consumeDirection() { - return directions.find(([action]) => input[action].pressed)?.[1] ?? null - }, - consumeRestart() { - return input.restart.pressed - } - }), - Game.Runtime.service(PixiHost, host), - Game.Runtime.service(SnakeNodes, nodes) - ), - resources: { - Score: 0, - PendingGrowth: 0, - SpawnSeed: randomSeedFromNow(), - GameOverReason: null - }, - machines: Game.Runtime.machines( - Game.Runtime.machine(GamePhase, "Playing") - ) - }).value - - const setup = runtime.tick(browserSetupSchedule) - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`) - - const intervalId = window.setInterval(() => { - input = keyboard.snapshot() - const ran = runtime.tick(browserUpdateSchedule) - if (!ran.ok) console.error("update failed", ran.error) - }, 140) - - return { - async destroy() { - window.clearInterval(intervalId) - keyboard.dispose() - nodes.clear() - application.destroy(true) - mount.replaceChildren() - } - } -} diff --git a/examples/pixi/space-invaders.ts b/examples/pixi/space-invaders.ts deleted file mode 100644 index 4fb84f4..0000000 --- a/examples/pixi/space-invaders.ts +++ /dev/null @@ -1,967 +0,0 @@ -import { Application, Container, Graphics } from "pixi.js" -import * as Matter from "matter-js" - -import { Keyboard } from "@typeonce/ritmo-browser" -import { Descriptor, Entity, Schema } from "@typeonce/ritmo" -import { NodeRegistry, RenderSync } from "@typeonce/ritmo-render" -import { Random } from "@typeonce/ritmo-math" -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi" - -interface BrowserExampleHandle { - destroy(): Promise -} - -const MAX_WIDTH = 800 -const MAX_HEIGHT = 520 -const PLAYER_WIDTH = 40 -const PLAYER_HEIGHT = 20 -const PLAYER_START_Y = MAX_HEIGHT - PLAYER_HEIGHT - 18 -const ENEMY_SIZE = 40 -const BULLET_WIDTH = 5 -const BULLET_HEIGHT = 10 -const ENEMY_SPAWN_COOLDOWN = 100 -const SHOOT_COOLDOWN = 300 -const MATTER_MAX_STEP_MS = 1000 / 60 - -const Root = Schema.defineRoot("SpaceInvaders") - -type RenderKind = "player" | "enemy" | "bullet" - -type RenderBodyValue = { - kind: RenderKind - width: number - height: number - color: number - stroke: number - anchorX: number - anchorY: number -} - -type DescentPatternValue = { - pattern: (time: number) => { dx: number; dy: number } -} - -type PixiHostValue = { - application: Application - scene: Container - clock: { - deltaFrames: number - deltaMilliseconds: number - } -} - -type MatterHostValue = { - engine: Matter.Engine - bodies: Map -} - -const Position = Descriptor.Component<{ x: number; y: number }>()("SpaceInvaders/Position") -const Velocity = Descriptor.Component<{ vx: number; vy: number; speed: number }>()("SpaceInvaders/Velocity") -const RenderBody = Descriptor.Component()("SpaceInvaders/RenderBody") -const Player = Descriptor.Component<{}>()("SpaceInvaders/Player") -const Enemy = Descriptor.Component<{ health: number }>()("SpaceInvaders/Enemy") -const Bullet = Descriptor.Component<{ damage: number }>()("SpaceInvaders/Bullet") -const DescentPattern = Descriptor.Component()("SpaceInvaders/DescentPattern") - -const FrameDelta = Descriptor.Resource()("SpaceInvaders/FrameDelta") -const DeltaMilliseconds = Descriptor.Resource()("SpaceInvaders/DeltaMilliseconds") -const ElapsedFrames = Descriptor.Resource()("SpaceInvaders/ElapsedFrames") -const EnemySpawnProgress = Descriptor.Resource()("SpaceInvaders/EnemySpawnProgress") -const ShootCooldown = Descriptor.Resource()("SpaceInvaders/ShootCooldown") -const Rng = Descriptor.ConstructedResource(Random)("SpaceInvaders/Rng") - -const DestroyEnemy = Descriptor.Event<{ - bullet: Entity.Handle - enemy: Entity.Handle -}>()("SpaceInvaders/DestroyEnemy") - -const InputManager = Descriptor.Service<{ - readonly isHeld: (action: "left" | "right" | "shoot") => boolean -}>()("SpaceInvaders/InputManager") - -const PixiHost = Descriptor.Service()("SpaceInvaders/PixiHost") -const MatterHost = Descriptor.Service()("SpaceInvaders/MatterHost") -const PixiNodes = Descriptor.Service>()("SpaceInvaders/PixiNodes") - -const Game = Schema.bind( - Schema.fragment({ - components: { - Position, - Velocity, - RenderBody, - Player, - Enemy, - Bullet, - DescentPattern - }, - requires: [ - Schema.requires(Player, Position, Velocity, RenderBody), - Schema.requires(Bullet, Position, Velocity, RenderBody), - Schema.requires(Enemy, Position, RenderBody, DescentPattern) - ], - resources: { - FrameDelta, - DeltaMilliseconds, - ElapsedFrames, - EnemySpawnProgress, - ShootCooldown, - Rng - }, - events: { - DestroyEnemy - } - }), - Root -) -const schema = Game.schema - -const PlayerVelocityQuery = Game.Query({ - selection: { - player: Game.Query.read(Player), - velocity: Game.Query.write(Velocity) - } -}) - -const PlayerPositionQuery = Game.Query({ - selection: { - player: Game.Query.read(Player), - position: Game.Query.read(Position) - } -}) - -const MovingQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.read(Velocity) - } -}) - -const EnemyDescentQuery = Game.Query({ - selection: { - enemy: Game.Query.read(Enemy), - position: Game.Query.write(Position), - descentPattern: Game.Query.read(DescentPattern) - } -}) - -const PlayerClampQuery = Game.Query({ - selection: { - player: Game.Query.read(Player), - position: Game.Query.write(Position), - renderBody: Game.Query.read(RenderBody) - } -}) - -const ColliderQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - renderBody: Game.Query.read(RenderBody) - } -}) - -const BulletCollisionQuery = Game.Query({ - selection: { - bullet: Game.Query.read(Bullet) - } -}) - -const EnemyCollisionQuery = Game.Query({ - selection: { - enemy: Game.Query.read(Enemy) - } -}) - -const BulletCullingQuery = Game.Query({ - selection: { - bullet: Game.Query.read(Bullet), - position: Game.Query.read(Position) - } -}) - -const EnemyCullingQuery = Game.Query({ - selection: { - enemy: Game.Query.read(Enemy), - position: Game.Query.read(Position) - } -}) - -const AddedRenderableQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - renderBody: Game.Query.read(RenderBody) - }, - filters: [Game.Query.added(RenderBody)] -}) - -const idleVelocity = { vx: 0, vy: 0, speed: 6 } as const -const shootUpVelocity = { vx: 0, vy: -10, speed: 6 } as const - -const makePlayerDraft = () => - Game.Command.spawn( - [Position, { x: 400, y: PLAYER_START_Y }], - [Velocity, idleVelocity], - [RenderBody, { - kind: "player", - width: PLAYER_WIDTH, - height: PLAYER_HEIGHT, - color: 0xf7c948, - stroke: 0xffefb8, - anchorX: 0.5, - anchorY: 0 - }], - [Player, {}] - ) - -const makeBulletDraft = (position: { x: number; y: number }) => - Game.Command.spawn( - [Position, position], - [Velocity, shootUpVelocity], - [RenderBody, { - kind: "bullet", - width: BULLET_WIDTH, - height: BULLET_HEIGHT, - color: 0xf5f5f5, - stroke: 0xffffff, - anchorX: 0.5, - anchorY: 0.5 - }], - [Bullet, { damage: 10 }] - ) - -const makeEnemyDraft = (position: { x: number; y: number }, pattern: DescentPatternValue) => - Game.Command.spawn( - [Position, position], - [RenderBody, { - kind: "enemy", - width: ENEMY_SIZE, - height: ENEMY_SIZE, - color: 0x4ecb71, - stroke: 0xb7ffcb, - anchorX: 0.5, - anchorY: 0.5 - }], - [Enemy, { health: 3 }], - [DescentPattern, pattern] - ) - -const DescentPatterns = { - sin: (amplitude: number, frequency: number, speed: number): DescentPatternValue => ({ - pattern: (time: number) => ({ - dx: Math.sin(time * frequency) * amplitude, - dy: speed - }) - }), - oscillatingArc: (amplitude: number, frequency: number, speed: number): DescentPatternValue => ({ - pattern: (time: number) => ({ - dx: Math.cos(time * frequency) * amplitude, - dy: speed - }) - }), - zigZag: { - pattern: (time: number) => ({ - dx: Math.sin(time * 0.05) > 0 ? 2 : -2, - dy: 1 - }) - } satisfies DescentPatternValue, - fastDown: { - pattern: (time: number) => ({ - dx: 0, - dy: 1 + time * 0.001 - }) - } satisfies DescentPatternValue, - spiral: (radiusStep: number, angularSpeed: number, speed: number): DescentPatternValue => ({ - pattern: (time: number) => { - const angle = time * angularSpeed - return { - dx: Math.cos(angle) * radiusStep, - dy: Math.sin(angle) * radiusStep + speed - } - } - }) -} as const - -const randomEnemyPattern = (random: Random.Random): DescentPatternValue => { - const roll = random.next() - if (roll < 0.15) { - return DescentPatterns.zigZag - } - if (roll < 0.3) { - return DescentPatterns.sin(random.range(2, 4), 0.1, 0.5) - } - if (roll < 0.45) { - return DescentPatterns.oscillatingArc(random.range(2, 4), 0.05, 0.25) - } - if (roll < 0.6) { - return DescentPatterns.spiral(random.range(2, 4), 0.05, 0.25) - } - return DescentPatterns.fastDown -} - -const bodyCenterFromPosition = ( - position: { x: number; y: number }, - renderBody: RenderBodyValue -) => ({ - x: position.x + renderBody.width * (0.5 - renderBody.anchorX), - y: position.y + renderBody.height * (0.5 - renderBody.anchorY) -}) - -const ensureMatterBody = ( - entityId: Entity.EntityId, - position: { x: number; y: number }, - renderBody: RenderBodyValue, - matter: MatterHostValue -): Matter.Body => { - const existing = matter.bodies.get(entityId) - if (existing) { - return existing - } - - const center = bodyCenterFromPosition(position, renderBody) - const body = Matter.Bodies.rectangle( - center.x, - center.y, - renderBody.width, - renderBody.height, - { isSensor: true } - ) - Matter.World.add(matter.engine.world, body) - matter.bodies.set(entityId, body) - return body -} - -const destroyMatterBody = ( - entityId: Entity.EntityId, - matter: MatterHostValue -) => { - const body = matter.bodies.get(entityId) - if (!body) { - return - } - - Matter.World.remove(matter.engine.world, body) - matter.bodies.delete(entityId) -} - -const renderBackdrop = (): Container => { - const backdrop = new Container() - - const panel = new Graphics() - panel.roundRect(0, 0, MAX_WIDTH, MAX_HEIGHT, 28) - panel.fill(0x091017) - panel.stroke({ - color: 0x24303b, - width: 2 - }) - backdrop.addChild(panel) - - const horizon = new Graphics() - horizon.rect(0, MAX_HEIGHT - 76, MAX_WIDTH, 76) - horizon.fill(0x101922) - horizon.stroke({ - color: 0x2d4052, - width: 1 - }) - backdrop.addChild(horizon) - - for (let index = 0; index < 48; index += 1) { - const star = new Graphics() - const size = index % 3 === 0 ? 2.4 : 1.4 - star.circle(0, 0, size) - star.fill(index % 5 === 0 ? 0xf7c948 : 0xc8e6ff) - star.alpha = index % 5 === 0 ? 0.9 : 0.55 - star.position.set( - ((index * 137) % (MAX_WIDTH - 40)) + 20, - ((index * 89) % 300) + 20 - ) - backdrop.addChild(star) - } - - return backdrop -} - -const createNode = (renderBody: RenderBodyValue): Graphics => { - const node = new Graphics() - - if (renderBody.kind === "player") { - node.roundRect(0, 0, renderBody.width, renderBody.height, 8) - node.fill(renderBody.color) - node.stroke({ - color: renderBody.stroke, - width: 2 - }) - - node.moveTo(renderBody.width * 0.5 - 7, 0) - node.lineTo(renderBody.width * 0.5, -10) - node.lineTo(renderBody.width * 0.5 + 7, 0) - node.fill(renderBody.color) - } else if (renderBody.kind === "enemy") { - node.roundRect(0, 0, renderBody.width, renderBody.height, 10) - node.fill(renderBody.color) - node.stroke({ - color: renderBody.stroke, - width: 2 - }) - - node.circle(renderBody.width * 0.3, renderBody.height * 0.35, 3) - node.circle(renderBody.width * 0.7, renderBody.height * 0.35, 3) - node.fill(0x091017) - node.rect(renderBody.width * 0.2, renderBody.height * 0.68, renderBody.width * 0.6, 4) - node.fill(0x091017) - } else { - node.roundRect(0, 0, renderBody.width, renderBody.height, 3) - node.fill(renderBody.color) - node.stroke({ - color: renderBody.stroke, - width: 1 - }) - } - - node.pivot.set(renderBody.width * renderBody.anchorX, renderBody.height * renderBody.anchorY) - return node -} - -const SpawnPlayerSystem = Game.System( - "SpaceInvaders/SpawnPlayer", - { - commands: Game.System.commands(Game.System.spawns(Player)) - }, - ({ commands }) => - { - commands.spawn(makePlayerDraft()) - } -) - -const CaptureFrameInputSystem = Game.System( - "SpaceInvaders/CaptureFrameInput", - { - resources: { - frameDelta: Game.System.writeResource(FrameDelta), - deltaMilliseconds: Game.System.writeResource(DeltaMilliseconds), - elapsedFrames: Game.System.writeResource(ElapsedFrames) - }, - services: { - pixi: Game.System.service(PixiHost) - } - }, - ({ resources, services }) => - { - resources.frameDelta.set(services.pixi.clock.deltaFrames) - resources.deltaMilliseconds.set(services.pixi.clock.deltaMilliseconds) - resources.elapsedFrames.update((elapsed) => elapsed + services.pixi.clock.deltaFrames) - } -) - -const PlayerInputSystem = Game.System( - "SpaceInvaders/PlayerInput", - { - queries: { - player: PlayerVelocityQuery - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ queries, services }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - player.value.data.velocity.update((velocity) => ({ - ...velocity, - vx: services.input.isHeld("left") - ? -velocity.speed - : services.input.isHeld("right") - ? velocity.speed - : 0 - })) - } -) - -const ShootingSystem = Game.System( - "SpaceInvaders/Shooting", - { - queries: { - player: PlayerPositionQuery - }, - resources: { - deltaMilliseconds: Game.System.readResource(DeltaMilliseconds), - shootCooldown: Game.System.writeResource(ShootCooldown) - }, - services: { - input: Game.System.service(InputManager) - }, - commands: Game.System.commands(Game.System.spawns(Bullet)) - }, - ({ queries, resources, services, commands }) => - { - resources.shootCooldown.update((cooldown) => - Math.max(0, cooldown - resources.deltaMilliseconds.get()) - ) - - const player = queries.player.singleOptional() - if (!player.ok || !player.value || !services.input.isHeld("shoot")) { - return - } - - if (resources.shootCooldown.get() > 0) { - return - } - - const position = player.value.data.position.get() - commands.spawn( - makeBulletDraft({ - x: position.x, - y: position.y - 10 - }) - ) - resources.shootCooldown.set(SHOOT_COOLDOWN) - } -) - -const EnemySpawnSystem = Game.System( - "SpaceInvaders/EnemySpawn", - { - resources: { - frameDelta: Game.System.readResource(FrameDelta), - enemySpawnProgress: Game.System.writeResource(EnemySpawnProgress), - rng: Game.System.writeResource(Rng) - }, - commands: Game.System.commands(Game.System.spawns(Enemy)) - }, - ({ resources, commands }) => - { - resources.enemySpawnProgress.update((progress) => progress + resources.frameDelta.get()) - if (resources.enemySpawnProgress.get() < ENEMY_SPAWN_COOLDOWN) { - return - } - - resources.enemySpawnProgress.set(0) - const random = Random.draw(resources.rng) - commands.spawn( - makeEnemyDraft( - { - x: random.range(0, MAX_WIDTH - 100), - y: 0 - }, - randomEnemyPattern(random) - ) - ) - } -) - -const MovementSystem = Game.System( - "SpaceInvaders/Movement", - { - queries: { - moving: MovingQuery - }, - resources: { - frameDelta: Game.System.readResource(FrameDelta) - } - }, - ({ queries, resources }) => - { - const delta = resources.frameDelta.get() - for (const match of queries.moving.each()) { - const velocity = match.data.velocity.get() - match.data.position.update((position) => ({ - x: position.x + velocity.vx * delta, - y: position.y + velocity.vy * delta - })) - } - } -) - -const ClampPlayerBoundsSystem = Game.System( - "SpaceInvaders/ClampPlayerBounds", - { - queries: { - player: PlayerClampQuery - } - }, - ({ queries }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const renderBody = player.value.data.renderBody.get() - const minX = renderBody.width * renderBody.anchorX - const maxX = MAX_WIDTH - renderBody.width * (1 - renderBody.anchorX) - - player.value.data.position.update((position) => ({ - x: Math.min(maxX, Math.max(minX, position.x)), - y: position.y - })) - } -) - -const EnemyDescentSystem = Game.System( - "SpaceInvaders/EnemyDescent", - { - queries: { - enemies: EnemyDescentQuery - }, - resources: { - frameDelta: Game.System.readResource(FrameDelta), - elapsedFrames: Game.System.readResource(ElapsedFrames) - } - }, - ({ queries, resources }) => - { - const delta = resources.frameDelta.get() - const elapsed = resources.elapsedFrames.get() - - for (const match of queries.enemies.each()) { - const { dx, dy } = match.data.descentPattern.get().pattern(elapsed) - match.data.position.update((position) => ({ - x: Math.min(MAX_WIDTH - 20, Math.max(20, position.x + dx * delta)), - y: position.y + dy * delta - })) - } - } -) - -const CreateMatterBodiesSystem = Game.System( - "SpaceInvaders/CreateMatterBodies", - { - queries: { - addedRenderables: AddedRenderableQuery - }, - services: { - matter: Game.System.service(MatterHost) - } - }, - ({ queries, services }) => - { - for (const match of queries.addedRenderables.each()) { - ensureMatterBody( - match.entity.id, - match.data.position.get(), - match.data.renderBody.get(), - services.matter - ) - } - } -) - -const SyncMatterBodyTransformsSystem = Game.System( - "SpaceInvaders/SyncMatterBodyTransforms", - { - queries: { - colliders: ColliderQuery - }, - resources: { - deltaMilliseconds: Game.System.readResource(DeltaMilliseconds) - }, - services: { - matter: Game.System.service(MatterHost) - } - }, - ({ queries, resources, services }) => - { - Matter.Engine.update( - services.matter.engine, - Math.min(resources.deltaMilliseconds.get(), MATTER_MAX_STEP_MS) - ) - - for (const match of queries.colliders.each()) { - const body = ensureMatterBody( - match.entity.id, - match.data.position.get(), - match.data.renderBody.get(), - services.matter - ) - const center = bodyCenterFromPosition( - match.data.position.get(), - match.data.renderBody.get() - ) - Matter.Body.setPosition(body, center) - } - } -) - -const DestroyMatterBodiesSystem = Game.System( - "SpaceInvaders/DestroyMatterBodies", - { - removed: { - renderables: Game.System.readRemoved(RenderBody) - }, - despawned: { - entities: Game.System.readDespawned() - }, - services: { - matter: Game.System.service(MatterHost) - } - }, - ({ removed, despawned, services }) => - { - for (const entityId of removed.renderables.all()) { - destroyMatterBody(entityId, services.matter) - } - - for (const entityId of despawned.entities.all()) { - destroyMatterBody(entityId, services.matter) - } - } -) - -const EnemyBulletCollisionSystem = Game.System( - "SpaceInvaders/EnemyBulletCollision", - { - queries: { - bullets: BulletCollisionQuery, - enemies: EnemyCollisionQuery - }, - events: { - destroyEnemy: Game.System.writeEvent(DestroyEnemy) - }, - services: { - matter: Game.System.service(MatterHost) - } - }, - ({ queries, events, services }) => - { - const consumedBullets = new Set() - const consumedEnemies = new Set() - - for (const bullet of queries.bullets.each()) { - const bulletId = bullet.entity.id - if (consumedBullets.has(bulletId)) { - continue - } - - const bulletBody = services.matter.bodies.get(bulletId) - if (!bulletBody) { - continue - } - - for (const enemy of queries.enemies.each()) { - const enemyId = enemy.entity.id - if (consumedEnemies.has(enemyId)) { - continue - } - - const enemyBody = services.matter.bodies.get(enemyId) - if (!enemyBody) { - continue - } - - if (Matter.Collision.collides(bulletBody, enemyBody)?.collided ?? false) { - consumedBullets.add(bulletId) - consumedEnemies.add(enemyId) - // The destroy event is read by a later system, so emit - // storage-safe handles and re-resolve them there. - events.destroyEnemy.emit({ - bullet: Game.Entity.handle(bulletId, Bullet), - enemy: Game.Entity.handle(enemyId, Enemy) - }) - break - } - } - } - } -) - -const EnemyDestroySystem = Game.System( - "SpaceInvaders/EnemyDestroy", - { - queries: { - bullets: BulletCollisionQuery, - enemies: EnemyCollisionQuery - }, - events: { - destroyEnemy: Game.System.readEvent(DestroyEnemy) - }, - commands: Game.System.commands(Game.System.despawns(Bullet, Enemy)) - }, - ({ queries, events, commands }) => - { - const despawned = new Set() - - for (const event of events.destroyEnemy.all()) { - // The event is readable now, but either entity may already be stale. - // Re-resolution keeps that failure explicit and typed. - const bullet = queries.bullets.getHandle(event.bullet) - if (bullet.ok && !despawned.has(bullet.value.entity.id)) { - commands.despawn(bullet.value.entity) - despawned.add(bullet.value.entity.id) - } - - const enemy = queries.enemies.getHandle(event.enemy) - if (enemy.ok && !despawned.has(enemy.value.entity.id)) { - commands.despawn(enemy.value.entity) - despawned.add(enemy.value.entity.id) - } - } - } -) - -const CullingSystem = Game.System( - "SpaceInvaders/Culling", - { - queries: { - bullets: BulletCullingQuery, - enemies: EnemyCullingQuery - }, - commands: Game.System.commands(Game.System.despawns(Bullet, Enemy)) - }, - ({ queries, commands }) => - { - for (const match of queries.bullets.each()) { - if (match.data.position.get().y >= -40) { - continue - } - - commands.despawn(match.entity) - } - - for (const match of queries.enemies.each()) { - if (match.data.position.get().y <= MAX_HEIGHT + 80) { - continue - } - - commands.despawn(match.entity) - } - } -) - -// Matter bodies stay custom (they feed collisions back into the ECS); Pixi -// nodes are a plain mirror of `RenderBody` + `Position`. -const render = RenderSync.system(Game, { - name: "SpaceInvaders/Render", - renderable: RenderBody, - transform: Position, - registry: PixiNodes, - create: ({ renderable }) => createNode(renderable), - apply: (node, { transform }) => node.position.set(transform.x, transform.y) -}) - -const gameplaySetupSchedule = Game.Schedule(SpawnPlayerSystem) - -const setupSchedule = Game.Schedule( - gameplaySetupSchedule, - Game.Schedule.applyDeferred(), - CreateMatterBodiesSystem, - render -) - -const updateSchedule = Game.Schedule( - CaptureFrameInputSystem, - PlayerInputSystem, - ShootingSystem, - EnemySpawnSystem, - Game.Schedule.applyDeferred(), - CreateMatterBodiesSystem, - MovementSystem, - ClampPlayerBoundsSystem, - EnemyDescentSystem, - SyncMatterBodyTransformsSystem, - EnemyBulletCollisionSystem, - EnemyDestroySystem, - CullingSystem, - Game.Schedule.applyDeferred(), - DestroyMatterBodiesSystem, - render -) - -export const startSpaceInvadersExample = async ( - mount: HTMLElement -): Promise => { - const application = new Application() - await application.init({ - antialias: true, - background: "#05080c", - width: MAX_WIDTH, - height: MAX_HEIGHT - }) - - const wrapper = document.createElement("section") - wrapper.className = "pixi-example-shell" - wrapper.appendChild(application.canvas) - mount.replaceChildren(wrapper) - - const scene = new Container() - application.stage.addChild(renderBackdrop()) - application.stage.addChild(scene) - - const keyboard = Keyboard.actions(window, { - left: ["ArrowLeft"], - right: ["ArrowRight"], - shoot: [" "] - }) - let input = keyboard.snapshot() - const nodes = PixiNodeRegistry.make(scene) - - const pixiHost: PixiHostValue = { - application, - scene, - clock: { - deltaFrames: 1, - deltaMilliseconds: 1000 / 60 - } - } - - const matterHost: MatterHostValue = { - engine: Matter.Engine.create({ - gravity: { scale: 0 } - }), - bodies: new Map() - } - - const made = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(InputManager, { - isHeld(action) { - return input[action].held - } - }), - Game.Runtime.service(PixiHost, pixiHost), - Game.Runtime.service(PixiNodes, nodes), - Game.Runtime.service(MatterHost, matterHost) - ), - resources: { - FrameDelta: pixiHost.clock.deltaFrames, - DeltaMilliseconds: pixiHost.clock.deltaMilliseconds, - ElapsedFrames: 0, - EnemySpawnProgress: ENEMY_SPAWN_COOLDOWN, - ShootCooldown: 0, - Rng: Random.seed(Date.now()) - } - }) - if (!made.ok) throw new Error(`invalid resources: ${JSON.stringify(made.error)}`) - const runtime = made.value - - const setup = runtime.tick(setupSchedule) - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`) - - const tick = (ticker: { readonly deltaMS: number }) => { - pixiHost.clock.deltaMilliseconds = ticker.deltaMS - pixiHost.clock.deltaFrames = ticker.deltaMS / (1000 / 60) - input = keyboard.snapshot() - const ran = runtime.tick(updateSchedule) - if (!ran.ok) console.error("update failed", ran.error) - } - - application.ticker.add(tick) - - return { - async destroy() { - application.ticker.remove(tick) - keyboard.dispose() - nodes.clear() - - for (const body of matterHost.bodies.values()) { - Matter.World.remove(matterHost.engine.world, body) - } - matterHost.bodies.clear() - Matter.Engine.clear(matterHost.engine) - - application.destroy(true) - mount.replaceChildren() - } - } -} diff --git a/examples/pixi/state-machine/constants.ts b/examples/pixi/state-machine/constants.ts deleted file mode 100644 index 7aa451d..0000000 --- a/examples/pixi/state-machine/constants.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const STAGE_WIDTH = 640 -export const STAGE_HEIGHT = 420 -export const PLAYER_SPEED = 220 -export const PLAYER_RADIUS = 16 -export const PICKUP_RADIUS = 10 -export const PICKUP_GOAL = 5 -export const ROUND_DURATION_SECONDS = 14 -export const COUNTDOWN_DURATION_SECONDS = 3 -export const NOTICE_DURATION_SECONDS = 0.95 diff --git a/examples/pixi/state-machine/content.ts b/examples/pixi/state-machine/content.ts deleted file mode 100644 index 8559c20..0000000 --- a/examples/pixi/state-machine/content.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const PICKUP_POINTS = [ - { x: 104, y: 108 }, - { x: 214, y: 86 }, - { x: 326, y: 134 }, - { x: 470, y: 112 }, - { x: 548, y: 186 }, - { x: 446, y: 286 }, - { x: 314, y: 332 }, - { x: 182, y: 302 }, - { x: 92, y: 252 } -] as const diff --git a/examples/pixi/state-machine/host.ts b/examples/pixi/state-machine/host.ts deleted file mode 100644 index 902cfbe..0000000 --- a/examples/pixi/state-machine/host.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { Keyboard } from "@typeonce/ritmo-browser" -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi" -import { Application, Container, Graphics } from "pixi.js" -import * as InputAxis from "@typeonce/ritmo-math/InputAxis" - -import { - PICKUP_RADIUS, - PLAYER_RADIUS, - STAGE_HEIGHT, - STAGE_WIDTH -} from "./constants.ts" -import type { - ActorKind, - BrowserHostValue, - StateMachineHud, - StateMachineInputManager, - Vector -} from "./types.ts" - -const createBoard = (width: number, height: number): Graphics => { - const board = new Graphics() - board.roundRect(0, 0, width, height, 22) - board.fill(0x0f151c) - - for (let x = 0; x <= width; x += 32) { - board.moveTo(x, 0) - board.lineTo(x, height) - } - - for (let y = 0; y <= height; y += 32) { - board.moveTo(0, y) - board.lineTo(width, y) - } - - board.stroke({ - color: 0x24303b, - width: 1 - }) - board.roundRect(0, 0, width, height, 22) - board.stroke({ - color: 0x5bc0be, - width: 2, - alpha: 0.55 - }) - return board -} - -export const createActorNode = (kind: ActorKind): Graphics => { - const node = new Graphics() - - if (kind === "player") { - node.circle(0, 0, PLAYER_RADIUS) - node.fill(0xf7c948) - node.stroke({ - color: 0xffefb8, - width: 3 - }) - return node - } - - node.moveTo(0, -PICKUP_RADIUS) - node.lineTo(PICKUP_RADIUS, 0) - node.lineTo(0, PICKUP_RADIUS) - node.lineTo(-PICKUP_RADIUS, 0) - node.closePath() - node.fill(0x5bc0be) - node.stroke({ - color: 0xbff7f2, - width: 2 - }) - return node -} - -const makeHud = () => { - const root = document.createElement("div") - root.style.position = "absolute" - root.style.inset = "0" - root.style.display = "grid" - root.style.gridTemplateRows = "auto 1fr auto" - root.style.pointerEvents = "none" - - const topBar = document.createElement("div") - topBar.style.display = "flex" - topBar.style.justifyContent = "space-between" - topBar.style.gap = "12px" - topBar.style.padding = "16px 18px 0" - - const score = document.createElement("span") - score.style.padding = "8px 12px" - score.style.borderRadius = "999px" - score.style.background = "rgba(7, 12, 16, 0.72)" - score.style.border = "1px solid rgba(255,255,255,0.08)" - score.style.fontFamily = "\"IBM Plex Mono\", monospace" - score.style.fontSize = "12px" - score.style.letterSpacing = "0.08em" - score.style.textTransform = "uppercase" - - const timer = score.cloneNode() as HTMLSpanElement - topBar.append(score, timer) - - const center = document.createElement("div") - center.style.position = "relative" - center.style.display = "grid" - center.style.placeItems = "center" - center.style.padding = "22px" - - const scrim = document.createElement("div") - scrim.style.position = "absolute" - scrim.style.inset = "0" - scrim.style.background = "linear-gradient(180deg, rgba(6, 9, 12, 0.2), rgba(6, 9, 12, 0.62))" - scrim.style.opacity = "0" - scrim.style.transition = "opacity 140ms ease" - - const overlay = document.createElement("div") - overlay.style.position = "relative" - overlay.style.display = "grid" - overlay.style.gap = "10px" - overlay.style.justifyItems = "center" - overlay.style.textAlign = "center" - overlay.style.maxWidth = "420px" - overlay.style.padding = "20px 24px" - overlay.style.borderRadius = "22px" - overlay.style.background = "rgba(8, 12, 16, 0.52)" - overlay.style.border = "1px solid rgba(255,255,255,0.08)" - overlay.style.backdropFilter = "blur(8px)" - overlay.style.opacity = "1" - overlay.style.transition = "opacity 140ms ease" - - const title = document.createElement("h2") - title.style.margin = "0" - title.style.fontSize = "42px" - title.style.lineHeight = "0.96" - title.style.letterSpacing = "-0.04em" - - const subtitle = document.createElement("p") - subtitle.style.margin = "0" - subtitle.style.color = "#bfd0dc" - subtitle.style.fontSize = "15px" - subtitle.style.lineHeight = "1.55" - - overlay.append(title, subtitle) - center.append(scrim, overlay) - - const footer = document.createElement("p") - footer.style.margin = "0" - footer.style.padding = "0 18px 18px" - footer.style.color = "#9ab0bf" - footer.style.fontSize = "13px" - footer.style.letterSpacing = "0.02em" - - const notice = document.createElement("p") - notice.style.margin = "0" - notice.style.padding = "0 18px 18px" - notice.style.color = "#f7c948" - notice.style.fontFamily = "\"IBM Plex Mono\", monospace" - notice.style.fontSize = "13px" - notice.style.letterSpacing = "0.06em" - notice.style.textTransform = "uppercase" - - const bottom = document.createElement("div") - bottom.style.display = "grid" - bottom.style.gap = "8px" - bottom.append(notice, footer) - - root.append(topBar, center, bottom) - - return { - root, - ui: { - scrim, - overlay, - title, - subtitle, - score, - timer, - footer, - notice - } satisfies StateMachineHud - } -} - -const bindings = { - left: ["ArrowLeft", "a"], - right: ["ArrowRight", "d"], - up: ["ArrowUp", "w"], - down: ["ArrowDown", "s"], - start: ["Enter"], - pause: ["p"] -} as const - -const movementFrom = (input: Keyboard.Snapshot): Vector => - InputAxis.vectorFromAxisValues( - InputAxis.axis(input.left.held, input.right.held), - InputAxis.axis(input.up.held, input.down.held) - ) - -export const createStateMachineBrowserHost = async (mount: HTMLElement) => { - const application = new Application() - await application.init({ - antialias: true, - background: "#0b1117", - width: STAGE_WIDTH, - height: STAGE_HEIGHT - }) - - const wrapper = document.createElement("section") - wrapper.className = "pixi-example-shell" - wrapper.style.position = "relative" - wrapper.style.overflow = "hidden" - wrapper.style.borderRadius = "24px" - wrapper.style.minHeight = `${STAGE_HEIGHT}px` - wrapper.appendChild(application.canvas) - - const { root, ui } = makeHud() - wrapper.appendChild(root) - mount.replaceChildren(wrapper) - - application.canvas.style.display = "block" - - const scene = new Container() - application.stage.addChild(createBoard(STAGE_WIDTH, STAGE_HEIGHT)) - application.stage.addChild(scene) - - const keyboard = Keyboard.actions(window, bindings) - let input = keyboard.snapshot() - // Start and pause are consumed once, so only one queue system reacts per press. - let startQueued = false - let pauseQueued = false - const nodes = PixiNodeRegistry.make(scene) - - const host: BrowserHostValue = { - application, - scene, - clock: { - deltaSeconds: 1 / 60 - }, - ui - } - - const inputManager: StateMachineInputManager = { - movement() { - return movementFrom(input) - }, - consumeStart() { - const next = startQueued - startQueued = false - return next - }, - consumePause() { - const next = pauseQueued - pauseQueued = false - return next - } - } - - return { - host, - nodes, - inputManager, - /** Reads the keyboard once per frame, before the update schedule runs. */ - captureInput() { - input = keyboard.snapshot() - startQueued = input.start.pressed - pauseQueued = input.pause.pressed - }, - async destroy() { - keyboard.dispose() - nodes.clear() - application.destroy(true) - mount.replaceChildren() - } - } -} diff --git a/examples/pixi/state-machine/main.ts b/examples/pixi/state-machine/main.ts deleted file mode 100644 index bc8fe84..0000000 --- a/examples/pixi/state-machine/main.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { createStateMachineBrowserHost } from "./host.ts" -import { createStateMachineRuntime } from "./runtime.ts" -import { setupSchedule, updateSchedule } from "./schedules.ts" - -interface BrowserExampleHandle { - destroy(): Promise -} - -const failedHandle = (): BrowserExampleHandle => ({ - async destroy() {} -}) - -export const startStateMachineExample = async (mount: HTMLElement): Promise => { - const browserHost = await createStateMachineBrowserHost(mount) - const runtime = createStateMachineRuntime(browserHost.host, browserHost.nodes, browserHost.inputManager) - if (!runtime.ok) { - await browserHost.destroy() - mount.textContent = runtime.error.message - return failedHandle() - } - runtime.value.tick(setupSchedule) - - const tick = (ticker: { readonly deltaMS: number }) => { - browserHost.host.clock.deltaSeconds = Math.min(ticker.deltaMS / 1000, 0.05) - browserHost.captureInput() - runtime.value.tick(updateSchedule) - } - - browserHost.host.application.ticker.add(tick) - - return { - async destroy() { - browserHost.host.application.ticker.remove(tick) - await browserHost.destroy() - } - } -} diff --git a/examples/pixi/state-machine/queries.ts b/examples/pixi/state-machine/queries.ts deleted file mode 100644 index c8ea91b..0000000 --- a/examples/pixi/state-machine/queries.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Game, Pickup, Player, Position } from "./schema.ts" - -export const PlayerQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - player: Game.Query.read(Player) - } -}) - -export const PlayerReadQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - player: Game.Query.read(Player) - } -}) - -export const PickupQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - pickup: Game.Query.read(Pickup) - } -}) diff --git a/examples/pixi/state-machine/runtime.ts b/examples/pixi/state-machine/runtime.ts deleted file mode 100644 index 3ac8bc5..0000000 --- a/examples/pixi/state-machine/runtime.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as Result from "@typeonce/ritmo/Result" -import { - ActorNodes, - Arena, - BrowserHost, - CountdownRemaining, - DeltaTime, - Game, - InputManager, - PickupGoal, - RoundState, - RoundTimeRemaining, - Score, - SessionState, - SpawnCursor, - TransitionNotice -} from "./schema.ts" -import { - COUNTDOWN_DURATION_SECONDS, - PICKUP_GOAL, - ROUND_DURATION_SECONDS -} from "./constants.ts" -import type { ActorNodes as ActorNodesValue, BrowserHostValue, StateMachineInputManager } from "./types.ts" - -const makeRuntime = ( - host: BrowserHostValue, - nodes: ActorNodesValue, - inputManager: StateMachineInputManager -) => { - const machines = Game.Runtime.machines( - Game.Runtime.machine(SessionState, "Title"), - // Entered whenever the session enters Round. - Game.Runtime.machine(RoundState, "Playing") - ) - - return Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(InputManager, inputManager), - Game.Runtime.service(BrowserHost, host), - Game.Runtime.service(ActorNodes, nodes) - ), - resources: { - Arena: { - width: host.application.screen.width, - height: host.application.screen.height - }, - DeltaTime: host.clock.deltaSeconds, - Score: 0, - PickupGoal: PICKUP_GOAL, - RoundTimeRemaining: ROUND_DURATION_SECONDS, - CountdownRemaining: COUNTDOWN_DURATION_SECONDS, - SpawnCursor: 0, - TransitionNotice: { - text: "", - ttl: 0 - } - }, - machines - }) -} - -export const createStateMachineRuntime = ( - host: BrowserHostValue, - nodes: ActorNodesValue, - inputManager: StateMachineInputManager -) => - Result.match(makeRuntime(host, nodes, inputManager), { - onSuccess: Result.success, - onFailure: (error) => - Result.failure({ - message: error.resources.Arena - ? "Invalid state-machine arena." - : "Invalid state-machine runtime resources." - }) - }) diff --git a/examples/pixi/state-machine/schedules.ts b/examples/pixi/state-machine/schedules.ts deleted file mode 100644 index 00d582d..0000000 --- a/examples/pixi/state-machine/schedules.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - CaptureFrameInputSystem, - CollectPickupsSystem, - RenderNodesSystem, - FadeTransitionNoticeSystem, - MovePlayerSystem, - QueueOutcomeSystem, - QueuePauseSystem, - QueueRestartSystem, - QueueResumeSystem, - QueueStartFromTitleSystem, - ResetRoundOnCountdownEnterSystem, - SpawnPlayerSystem, - SyncHudSystem, - TickCountdownSystem, - TickRoundClockSystem, - WriteTransitionNoticeSystem -} from "./systems.ts" -import { Game, SessionState } from "./schema.ts" - -export const setupSchedule = Game.Schedule( - SpawnPlayerSystem, - Game.Schedule.applyDeferred(), - RenderNodesSystem, - SyncHudSystem -) - -export const stateTransitions = Game.Schedule.transitions( - Game.Schedule.onEnter(SessionState, "Countdown", [ - // Entering Countdown is the explicit reset boundary for the next round. - ResetRoundOnCountdownEnterSystem - ]) -) - -export const updateSchedule = Game.Schedule( - CaptureFrameInputSystem, - QueueStartFromTitleSystem, - QueueRestartSystem, - QueuePauseSystem, - QueueResumeSystem, - TickCountdownSystem, - MovePlayerSystem, - CollectPickupsSystem, - TickRoundClockSystem, - QueueOutcomeSystem, - Game.Schedule.applyStateTransitions(stateTransitions), - Game.Schedule.applyDeferred(), - // Host sync runs after the commands above are applied, so it sees this tick's spawns. - WriteTransitionNoticeSystem, - FadeTransitionNoticeSystem, - RenderNodesSystem, - SyncHudSystem -) diff --git a/examples/pixi/state-machine/schema.ts b/examples/pixi/state-machine/schema.ts deleted file mode 100644 index 9af46c4..0000000 --- a/examples/pixi/state-machine/schema.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { Descriptor, Schema } from "@typeonce/ritmo" -import * as Size2 from "@typeonce/ritmo-math/Size2" -import * as Vector2 from "@typeonce/ritmo-math/Vector2" -import type { - ActorKind, - ActorNodes as ActorNodesValue, - BrowserHostValue, - NoticeValue, - StateMachineInputManager, -} from "./types.ts" - -export const Position = Descriptor.ConstructedComponent(Vector2)("StateMachineExample/Position") -export const Actor = Descriptor.Component<{ kind: ActorKind }>()("StateMachineExample/Actor") -export const Player = Descriptor.Component<{}>()("StateMachineExample/Player") -export const Pickup = Descriptor.Component<{}>()("StateMachineExample/Pickup") - -export const Arena = Descriptor.ConstructedResource(Size2)("StateMachineExample/Arena") -export const DeltaTime = Descriptor.Resource()("StateMachineExample/DeltaTime") -export const Score = Descriptor.Resource()("StateMachineExample/Score") -export const PickupGoal = Descriptor.Resource()("StateMachineExample/PickupGoal") -export const RoundTimeRemaining = Descriptor.Resource()("StateMachineExample/RoundTimeRemaining") -export const CountdownRemaining = Descriptor.Resource()("StateMachineExample/CountdownRemaining") -export const SpawnCursor = Descriptor.Resource()("StateMachineExample/SpawnCursor") -export const TransitionNotice = Descriptor.Resource()("StateMachineExample/TransitionNotice") - -export const InputManager = Descriptor.Service()("StateMachineExample/InputManager") -export const BrowserHost = Descriptor.Service()("StateMachineExample/BrowserHost") -export const ActorNodes = Descriptor.Service()("StateMachineExample/ActorNodes") - -export const Game = Schema.bind( - Schema.fragment({ - components: { - Position, - Actor, - Player, - Pickup - }, - requires: [ - Schema.requires(Player, Position, Actor), - Schema.requires(Pickup, Position, Actor) - ], - resources: { - Arena, - DeltaTime, - Score, - PickupGoal, - RoundTimeRemaining, - CountdownRemaining, - SpawnCursor, - TransitionNotice - } - }) -) - -export const schema = Game.schema - -export const SessionState = Game.StateMachine( - "StateMachineExample/SessionState", - ["Title", "Countdown", "Round"] as const -) - -// The round's own flow exists only during a round: a sub-state of the session. -// It enters its runtime-provided value (Playing) whenever the session enters -// Round and has no value elsewhere, so inState(RoundState, ...) alone implies -// the session is in Round. -export const RoundState = Game.StateMachine( - "StateMachineExample/RoundState", - ["Playing", "Paused", "Victory", "Defeat"] as const, - { within: [SessionState, "Round"] } -) - -export const GameplayWhen = [Game.Condition.inState(RoundState, "Playing")] as const - -// Pickups live for one round: entering Countdown (a restart) despawns them, -// before the onEnter reset spawns the next round's. -export const PickupScope = Game.EntityScope("StateMachineExample/Pickups", { - despawnOnEnter: [SessionState, "Countdown"] -}) diff --git a/examples/pixi/state-machine/systems.ts b/examples/pixi/state-machine/systems.ts deleted file mode 100644 index 0c27b3f..0000000 --- a/examples/pixi/state-machine/systems.ts +++ /dev/null @@ -1,506 +0,0 @@ -import * as Scalar from "@typeonce/ritmo-math/Scalar" -import * as Vector2 from "@typeonce/ritmo-math/Vector2" -import { RenderSync } from "@typeonce/ritmo-render" -import { PICKUP_POINTS } from "./content.ts" -import { - COUNTDOWN_DURATION_SECONDS, - NOTICE_DURATION_SECONDS, - PICKUP_RADIUS, - PLAYER_RADIUS, - PLAYER_SPEED, - ROUND_DURATION_SECONDS, - STAGE_HEIGHT, - STAGE_WIDTH -} from "./constants.ts" -import { - PickupQuery, - PlayerQuery, - PlayerReadQuery -} from "./queries.ts" -import { createActorNode } from "./host.ts" -import { - Actor, - ActorNodes, - Arena, - BrowserHost, - CountdownRemaining, - DeltaTime, - Game, - GameplayWhen, - InputManager, - Pickup, - PickupGoal, - PickupScope, - Position, - RoundState, - RoundTimeRemaining, - Score, - SessionState, - SpawnCursor, - TransitionNotice, - Player -} from "./schema.ts" -import type { Vector } from "./types.ts" - -const clamp = (value: number, min: number, max: number): number => { - const nextValue = Scalar.Finite.option(value) - const nextMin = Scalar.Finite.option(min) - const nextMax = Scalar.Finite.option(max) - if (!nextValue || !nextMin || !nextMax) { - return value - } - - return Scalar.clamp(nextValue, nextMin, nextMax) -} - -const distanceSquared = (left: Vector, right: Vector): number => { - return Vector2.lengthSquared(Vector2.subtract(left, right)) -} - -const makePickupDraft = (position: { readonly x: number; readonly y: number }) => { - return Game.Command.spawn( - Game.Command.entryRaw(Position, position), - Game.Command.entry(Actor, { kind: "pickup" }), - Game.Command.entry(Pickup, {}) - ) -} - -/** - * The player draft, validated once at module load through the constructed - * `Position` descriptor. - */ -const playerDraft = Game.Command.spawn( - Game.Command.entryRaw(Position, { x: STAGE_WIDTH * 0.5, y: STAGE_HEIGHT * 0.5 }), - Game.Command.entry(Actor, { kind: "player" }), - Game.Command.entry(Player, {}) -) - -export const SpawnPlayerSystem = Game.System( - "StateMachineExample/SpawnPlayer", - { - commands: Game.System.commands(Game.System.spawns(Player)) - }, - ({ commands }) => - { - if (!playerDraft.ok) { - return - } - commands.spawn(playerDraft.value) - } -) - -export const CaptureFrameInputSystem = Game.System( - "StateMachineExample/CaptureFrameInput", - { - resources: { - deltaTime: Game.System.writeResource(DeltaTime) - }, - services: { - host: Game.System.service(BrowserHost) - } - }, - ({ resources, services }) => - { - resources.deltaTime.set(services.host.clock.deltaSeconds) - } -) - -export const QueueStartFromTitleSystem = Game.System( - "StateMachineExample/QueueStartFromTitle", - { - when: [Game.Condition.inState(SessionState, "Title")], - nextMachines: { - session: Game.System.nextState(SessionState) - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ nextMachines, services }) => - { - if (services.input.consumeStart()) { - // Restart is routed back through Countdown so reset work stays on the - // transition boundary instead of happening immediately in input code. - nextMachines.session.set("Countdown") - } - } -) - -export const QueueRestartSystem = Game.System( - "StateMachineExample/QueueRestart", - { - when: [Game.Condition.or( - Game.Condition.inState(RoundState, "Victory"), - Game.Condition.inState(RoundState, "Defeat") - )], - nextMachines: { - session: Game.System.nextState(SessionState) - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ nextMachines, services }) => - { - if (services.input.consumeStart()) { - nextMachines.session.set("Countdown") - } - } -) - -export const QueuePauseSystem = Game.System( - "StateMachineExample/QueuePause", - { - when: [Game.Condition.inState(RoundState, "Playing")], - nextMachines: { - round: Game.System.nextState(RoundState) - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ nextMachines, services }) => - { - if (services.input.consumePause()) { - nextMachines.round.set("Paused") - } - } -) - -export const QueueResumeSystem = Game.System( - "StateMachineExample/QueueResume", - { - when: [Game.Condition.inState(RoundState, "Paused")], - nextMachines: { - round: Game.System.nextState(RoundState) - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ nextMachines, services }) => - { - if (services.input.consumePause()) { - nextMachines.round.set("Playing") - } - } -) - -export const TickCountdownSystem = Game.System( - "StateMachineExample/TickCountdown", - { - when: [Game.Condition.inState(SessionState, "Countdown")], - resources: { - deltaTime: Game.System.readResource(DeltaTime), - countdown: Game.System.writeResource(CountdownRemaining) - }, - nextMachines: { - session: Game.System.nextState(SessionState) - } - }, - ({ resources, nextMachines }) => - { - const remaining = clamp(resources.countdown.get() - resources.deltaTime.get(), 0, COUNTDOWN_DURATION_SECONDS) - resources.countdown.set(remaining) - if (remaining <= 0) { - // RoundState enters Playing with the session's Round. - nextMachines.session.set("Round") - } - } -) - -export const MovePlayerSystem = Game.System( - "StateMachineExample/MovePlayer", - { - when: GameplayWhen, - queries: { - player: PlayerQuery - }, - resources: { - arena: Game.System.readResource(Arena), - deltaTime: Game.System.readResource(DeltaTime) - }, - services: { - input: Game.System.service(InputManager) - } - }, - ({ queries, resources, services }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const movement = services.input.movement() - const dt = resources.deltaTime.get() - const arena = resources.arena.get() - const position = player.value.data.position.get() - - player.value.data.position.setRaw({ - x: clamp(position.x + movement.x * PLAYER_SPEED * dt, PLAYER_RADIUS, arena.width - PLAYER_RADIUS), - y: clamp(position.y + movement.y * PLAYER_SPEED * dt, PLAYER_RADIUS, arena.height - PLAYER_RADIUS) - }) - } -) - -export const CollectPickupsSystem = Game.System( - "StateMachineExample/CollectPickups", - { - when: GameplayWhen, - queries: { - player: PlayerReadQuery, - pickups: PickupQuery - }, - resources: { - score: Game.System.writeResource(Score) - }, - commands: Game.System.commands(Game.System.despawns(Pickup)) - }, - ({ queries, resources, commands }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const playerPosition = player.value.data.position.get() - for (const pickup of queries.pickups.each()) { - if (distanceSquared(playerPosition, pickup.data.position.get()) <= (PLAYER_RADIUS + PICKUP_RADIUS) ** 2) { - commands.despawn(pickup.entity) - resources.score.update((value) => value + 1) - } - } - } -) - -export const TickRoundClockSystem = Game.System( - "StateMachineExample/TickRoundClock", - { - when: GameplayWhen, - resources: { - deltaTime: Game.System.readResource(DeltaTime), - roundTime: Game.System.writeResource(RoundTimeRemaining) - } - }, - ({ resources }) => - { - resources.roundTime.update((value) => clamp(value - resources.deltaTime.get(), 0, ROUND_DURATION_SECONDS)) - } -) - -export const QueueOutcomeSystem = Game.System( - "StateMachineExample/QueueOutcome", - { - when: GameplayWhen, - resources: { - score: Game.System.readResource(Score), - goal: Game.System.readResource(PickupGoal), - roundTime: Game.System.readResource(RoundTimeRemaining) - }, - nextMachines: { - round: Game.System.nextState(RoundState) - } - }, - ({ resources, nextMachines }) => - { - if (resources.score.get() >= resources.goal.get()) { - nextMachines.round.setIfChanged("Victory") - return - } - - if (resources.roundTime.get() <= 0) { - nextMachines.round.setIfChanged("Defeat") - } - } -) - -export const ResetRoundOnCountdownEnterSystem = Game.System( - "StateMachineExample/ResetRoundOnCountdownEnter", - { - queries: { - player: PlayerQuery - }, - resources: { - score: Game.System.writeResource(Score), - roundTime: Game.System.writeResource(RoundTimeRemaining), - countdown: Game.System.writeResource(CountdownRemaining), - goal: Game.System.readResource(PickupGoal), - cursor: Game.System.writeResource(SpawnCursor) - }, - commands: Game.System.commands(Game.System.spawns(Pickup)) - }, - ({ queries, resources, commands }) => - { - const player = queries.player.singleOptional() - if (player.ok && player.value) { - player.value.data.position.setRaw({ x: STAGE_WIDTH * 0.5, y: STAGE_HEIGHT * 0.5 }) - } - - // The previous round's pickups were despawned with PickupScope on entering Countdown. - - const cursor = resources.cursor.get() - const goal = resources.goal.get() - for (let index = 0; index < goal; index += 1) { - const point = PICKUP_POINTS[(cursor + index) % PICKUP_POINTS.length] ?? PICKUP_POINTS[0]! - const pickupDraft = makePickupDraft(point) - if (pickupDraft.ok) { - commands.spawnIn(PickupScope, pickupDraft.value) - } - } - - resources.cursor.set((cursor + 1) % PICKUP_POINTS.length) - resources.score.set(0) - resources.roundTime.set(ROUND_DURATION_SECONDS) - resources.countdown.set(COUNTDOWN_DURATION_SECONDS) - } -) - -export const WriteTransitionNoticeSystem = Game.System( - "StateMachineExample/WriteTransitionNotice", - { - transitionEvents: { - session: Game.System.readTransitionEvent(SessionState), - round: Game.System.readTransitionEvent(RoundState) - }, - resources: { - notice: Game.System.writeResource(TransitionNotice) - } - }, - ({ transitionEvents, resources }) => - { - const messages = [ - ...transitionEvents.session.all().map((event) => `Session ${event.from} -> ${event.to}`), - // A round change within a round; RoundState entering or leaving with the session is in the session message. - ...transitionEvents.round.all().flatMap((event) => event.from === undefined || event.to === undefined ? [] : [`Round ${event.from} -> ${event.to}`]) - ] - - if (messages.length === 0) { - return - } - - resources.notice.set({ - text: messages.join(" | "), - ttl: NOTICE_DURATION_SECONDS - }) - } -) - -export const FadeTransitionNoticeSystem = Game.System( - "StateMachineExample/FadeTransitionNotice", - { - resources: { - deltaTime: Game.System.readResource(DeltaTime), - notice: Game.System.writeResource(TransitionNotice) - } - }, - ({ resources }) => - { - const notice = resources.notice.get() - if (notice.ttl <= 0) { - return - } - - const ttl = clamp(notice.ttl - resources.deltaTime.get(), 0, NOTICE_DURATION_SECONDS) - resources.notice.set({ - text: ttl > 0 ? notice.text : "", - ttl - }) - } -) - -const render = RenderSync.system(Game, { - name: "StateMachineExample/Render", - renderable: Actor, - transform: Position, - registry: ActorNodes, - create: ({ renderable }) => createActorNode(renderable.kind), - apply: (node, { transform }) => node.position.set(transform.x, transform.y) -}) - -export const RenderNodesSystem = render - -export const SyncHudSystem = Game.System( - "StateMachineExample/SyncHud", - { - resources: { - score: Game.System.changedResource(Score), - goal: Game.System.changedResource(PickupGoal), - roundTime: Game.System.readResource(RoundTimeRemaining), - countdown: Game.System.readResource(CountdownRemaining), - notice: Game.System.readResource(TransitionNotice) - }, - machines: { - session: Game.System.machine(SessionState), - round: Game.System.machine(RoundState) - }, - services: { - host: Game.System.service(BrowserHost) - } - }, - ({ resources, machines, services }) => - { - const session = machines.session.get() - const round = machines.round.get() - const score = resources.score.get() - const goal = resources.goal.get() - const roundTime = resources.roundTime.get() - const countdown = resources.countdown.get() - const notice = resources.notice.get() - const hud = services.host.ui - - // The timer and countdown move every frame; the score label only when the score or goal changed. - if (resources.score.changed() || resources.goal.changed()) hud.score.textContent = `Score ${score}/${goal}` - hud.timer.textContent = `Time ${Math.max(0, roundTime).toFixed(1)}s` - hud.notice.textContent = notice.ttl > 0 ? notice.text : "" - hud.notice.style.opacity = notice.ttl > 0 ? String(clamp(notice.ttl / NOTICE_DURATION_SECONDS, 0, 1)) : "0" - - if (session === "Title") { - hud.scrim.style.opacity = "0.48" - hud.overlay.style.opacity = "1" - hud.title.textContent = "State Machine Sprint" - hud.subtitle.textContent = "Press Enter to start. Arrows or WASD move. P pauses during play." - hud.footer.textContent = "The round machine is a sub-state of the session: it exists only during a round, so countdown, play, pause, win, and lose stay explicit." - return - } - - if (session === "Countdown") { - hud.scrim.style.opacity = "0.22" - hud.overlay.style.opacity = "1" - hud.title.textContent = String(Math.max(1, Math.ceil(countdown))) - hud.subtitle.textContent = "Queued machine changes stay invisible until applyStateTransitions() commits them." - hud.footer.textContent = "Restart resets the round through a typed onEnter(Countdown) transition bundle." - return - } - - switch (round) { - case "Playing": - hud.scrim.style.opacity = "0" - hud.overlay.style.opacity = "0" - hud.title.textContent = "" - hud.subtitle.textContent = "" - hud.footer.textContent = "P queues Paused. Victory and defeat are queued by gameplay systems." - break - case "Paused": - hud.scrim.style.opacity = "0.54" - hud.overlay.style.opacity = "1" - hud.title.textContent = "Paused" - hud.subtitle.textContent = "Press P to queue Playing again." - hud.footer.textContent = "Only the round machine changes here. Session state stays committed to Round." - break - case "Victory": - hud.scrim.style.opacity = "0.46" - hud.overlay.style.opacity = "1" - hud.title.textContent = "Victory" - hud.subtitle.textContent = "You collected every pickup in time." - hud.footer.textContent = "Press Enter to restart through Countdown." - break - case "Defeat": - hud.scrim.style.opacity = "0.5" - hud.overlay.style.opacity = "1" - hud.title.textContent = "Defeat" - hud.subtitle.textContent = "The timer expired before you reached the goal." - hud.footer.textContent = "Press Enter to restart through Countdown." - break - } - } -) diff --git a/examples/pixi/state-machine/types.ts b/examples/pixi/state-machine/types.ts deleted file mode 100644 index 42f4844..0000000 --- a/examples/pixi/state-machine/types.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { NodeRegistry } from "@typeonce/ritmo-render" -import type { Application, Container, Graphics } from "pixi.js" -import type * as Size2Value from "@typeonce/ritmo-math/Size2" -import type * as Vector2Value from "@typeonce/ritmo-math/Vector2" - -export type Vector = Vector2Value.Vector2 - -export type NoticeValue = { - text: string - ttl: number -} - -export type ActorKind = "player" | "pickup" - -export type StateMachineHud = { - readonly scrim: HTMLDivElement - readonly overlay: HTMLDivElement - readonly title: HTMLHeadingElement - readonly subtitle: HTMLParagraphElement - readonly score: HTMLSpanElement - readonly timer: HTMLSpanElement - readonly footer: HTMLParagraphElement - readonly notice: HTMLParagraphElement -} - -export type BrowserHostValue = { - readonly application: Application - readonly scene: Container - readonly clock: { - deltaSeconds: number - } - readonly ui: StateMachineHud -} - -export type ActorNodes = NodeRegistry.NodeRegistry - -export type StateMachineInputManager = { - readonly movement: () => Vector - readonly consumeStart: () => boolean - readonly consumePause: () => boolean -} - -export type ArenaSize = Size2Value.Size2 diff --git a/examples/pixi/tsconfig.json b/examples/pixi/tsconfig.json deleted file mode 100644 index eca7f23..0000000 --- a/examples/pixi/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["./**/*.ts"] -} diff --git a/examples/platformer/browser.ts b/examples/platformer/browser.ts deleted file mode 100644 index aea40bd..0000000 --- a/examples/platformer/browser.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { startPlatformerExample } from "./main.ts" - -const mount = document.querySelector("[data-platformer-root]") - -if (!mount) { - throw new Error("Missing [data-platformer-root] host element in examples/platformer/index.html") -} - -void startPlatformerExample(mount) diff --git a/examples/platformer/constants.ts b/examples/platformer/constants.ts deleted file mode 100644 index cbf1f1d..0000000 --- a/examples/platformer/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const MAX_DELTA_SECONDS = 1 / 20 - -export const WORLD_WIDTH = 2304 -export const WORLD_HEIGHT = 960 -export const GROUND_HEIGHT = 96 -export const KILL_PLANE_Y = WORLD_HEIGHT + 220 - -export const PLAYER_WIDTH = 46 -export const PLAYER_HEIGHT = 60 - -export const WALK_SPEED = 280 -export const RUN_SPEED = 420 -export const GROUND_ACCELERATION = 2800 -export const GROUND_DECELERATION = 3400 -export const AIR_ACCELERATION = 1500 -export const GRAVITY = 2200 -export const JUMP_VELOCITY = 780 -export const MAX_FALL_SPEED = 1200 - -export const CAMERA_LOOK_AHEAD = 84 diff --git a/examples/platformer/content.ts b/examples/platformer/content.ts deleted file mode 100644 index 7558bec..0000000 --- a/examples/platformer/content.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { GROUND_HEIGHT, KILL_PLANE_Y, PLAYER_HEIGHT, WORLD_HEIGHT, WORLD_WIDTH } from "./constants.ts" - -export type LevelSolidLayout = { - x: number - y: number - width: number - height: number - kind: "ground" | "block" | "pipe" -} - -const groundCenterY = WORLD_HEIGHT - GROUND_HEIGHT * 0.5 - -export const playerSpawn = { - x: 180, - y: groundCenterY - GROUND_HEIGHT * 0.5 - PLAYER_HEIGHT * 0.5 -} as const - -export const levelBounds = { - width: WORLD_WIDTH, - height: WORLD_HEIGHT, - killPlaneY: KILL_PLANE_Y -} as const - -export const levelSolids: ReadonlyArray = [ - { kind: "ground", x: 288, y: groundCenterY, width: 576, height: GROUND_HEIGHT }, - { kind: "ground", x: 1088, y: groundCenterY, width: 640, height: GROUND_HEIGHT }, - { kind: "ground", x: 1824, y: groundCenterY, width: 384, height: GROUND_HEIGHT }, - { kind: "ground", x: 2216, y: groundCenterY, width: 176, height: GROUND_HEIGHT }, - { kind: "block", x: 660, y: 772, width: 128, height: 32 }, - { kind: "block", x: 860, y: 708, width: 128, height: 32 }, - { kind: "block", x: 1030, y: 648, width: 128, height: 32 }, - { kind: "block", x: 1210, y: 708, width: 160, height: 32 }, - { kind: "pipe", x: 1470, y: 792, width: 96, height: 240 }, - { kind: "block", x: 1690, y: 736, width: 192, height: 32 }, - { kind: "block", x: 1910, y: 676, width: 128, height: 32 } -] as const diff --git a/examples/platformer/drafts.ts b/examples/platformer/drafts.ts deleted file mode 100644 index 7aa407e..0000000 --- a/examples/platformer/drafts.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { PLAYER_HEIGHT, PLAYER_WIDTH } from "./constants.ts" -import { playerSpawn, type LevelSolidLayout } from "./content.ts" -import { Collider, Game, Player, Position, Renderable, Solid, Velocity } from "./schema.ts" - -const renderableForSolid = ( - layout: LevelSolidLayout -): { - kind: "ground" | "block" | "pipe" - width: number - height: number - color: number - accent: number -} => { - switch (layout.kind) { - case "ground": - return { - kind: "ground", - width: layout.width, - height: layout.height, - color: 0x8b4f28, - accent: 0xe4bf75 - } - case "pipe": - return { - kind: "pipe", - width: layout.width, - height: layout.height, - color: 0x2d8c5b, - accent: 0xa9ffd0 - } - case "block": - return { - kind: "block", - width: layout.width, - height: layout.height, - color: 0xc06a2f, - accent: 0xffd48d - } - } -} - -/** - * The player draft, validated once at module load through the constructed - * descriptors. Drafts are plain values, so every spawn reuses it. - */ -export const playerDraft = Game.Command.spawn( - Game.Command.entryRaw(Position, playerSpawn), - Game.Command.entryRaw(Velocity, { x: 0, y: 0 }), - Game.Command.entryRaw(Collider, { width: PLAYER_WIDTH, height: PLAYER_HEIGHT }), - Game.Command.entry(Renderable, { - kind: "player", - width: PLAYER_WIDTH, - height: PLAYER_HEIGHT, - color: 0xd94841, - accent: 0xfff2d5 - }), - Game.Command.entry(Player, {}) -) - -export const makeSolidDraft = (layout: LevelSolidLayout) => { - return Game.Command.spawn( - Game.Command.entryRaw(Position, { x: layout.x, y: layout.y }), - Game.Command.entryRaw(Collider, { width: layout.width, height: layout.height }), - Game.Command.entry(Renderable, renderableForSolid(layout)), - Game.Command.entry(Solid, {}) - ) -} diff --git a/examples/platformer/host.ts b/examples/platformer/host.ts deleted file mode 100644 index 9efdb97..0000000 --- a/examples/platformer/host.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Keyboard } from "@typeonce/ritmo-browser" -import { PixiNodeRegistry } from "@typeonce/ritmo-pixi" -import { Application, Container } from "pixi.js" - -import { createHud } from "./render/hud.ts" -import { createWorldBackdrop } from "./render/backdrop.ts" -import type { InputStateValue, PlatformerHostValue } from "./types.ts" - -export type PlatformerBrowserHost = { - readonly host: PlatformerHostValue - readonly inputManager: { - readonly snapshot: () => InputStateValue - } - destroy(): Promise -} - -export const createPlatformerBrowserHost = async ( - mount: HTMLElement -): Promise => { - const application = new Application() - await application.init({ - antialias: true, - backgroundAlpha: 0, - resizeTo: mount - }) - - const shell = document.createElement("section") - shell.className = "platformer-shell" - - const viewport = document.createElement("div") - viewport.className = "platformer-shell__viewport" - viewport.appendChild(application.canvas) - - const hud = createHud() - shell.appendChild(viewport) - shell.appendChild(hud.root) - mount.replaceChildren(shell) - - const world = new Container() - const actorLayer = new Container() - world.addChild(createWorldBackdrop()) - world.addChild(actorLayer) - application.stage.addChild(world) - - const keyboard = Keyboard.actions(window, { - left: ["ArrowLeft", "a"], - right: ["ArrowRight", "d"], - jump: [" ", "ArrowUp", "w"], - run: ["Shift"], - restart: ["Enter"] - }) - let pointerRestartQueued = false - const onPointerDown = () => { - pointerRestartQueued = true - } - window.addEventListener("pointerdown", onPointerDown) - - const host: PlatformerHostValue = { - application, - world, - actorLayer, - nodes: PixiNodeRegistry.make(actorLayer), - hud: hud.refs, - clock: { - deltaSeconds: 1 / 60 - } - } - - return { - host, - inputManager: { - snapshot() { - const input = keyboard.snapshot() - const nextState: InputStateValue = { - left: input.left.held, - right: input.right.held, - jumpPressed: input.jump.held, - jumpJustPressed: input.jump.pressed, - runPressed: input.run.held, - restartJustPressed: input.restart.pressed || pointerRestartQueued - } - pointerRestartQueued = false - return nextState - } - }, - async destroy() { - keyboard.dispose() - window.removeEventListener("pointerdown", onPointerDown) - - host.nodes.clear() - application.destroy(true) - mount.replaceChildren() - } - } -} diff --git a/examples/platformer/index.html b/examples/platformer/index.html deleted file mode 100644 index a62c827..0000000 --- a/examples/platformer/index.html +++ /dev/null @@ -1,164 +0,0 @@ - - - - - - ritmo Platformer Example - - - -
- - - diff --git a/examples/platformer/main.ts b/examples/platformer/main.ts deleted file mode 100644 index 1050ae2..0000000 --- a/examples/platformer/main.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { MAX_DELTA_SECONDS } from "./constants.ts" -import { createPlatformerBrowserHost } from "./host.ts" -import { createPlatformerRuntime } from "./runtime.ts" -import { setupSchedule, updateSchedule } from "./schedules.ts" - -interface BrowserExampleHandle { - destroy(): Promise -} - -const failedHandle = (): BrowserExampleHandle => ({ - async destroy() {} -}) - -export const startPlatformerExample = async (mount: HTMLElement): Promise => { - const browserHost = await createPlatformerBrowserHost(mount) - const runtime = createPlatformerRuntime(browserHost.host, browserHost.inputManager) - if (!runtime.ok) { - await browserHost.destroy() - mount.textContent = runtime.error.message - return failedHandle() - } - runtime.value.tick(setupSchedule) - - const tick = (ticker: { readonly deltaMS: number }) => { - browserHost.host.clock.deltaSeconds = Math.min(ticker.deltaMS / 1000, MAX_DELTA_SECONDS) - runtime.value.tick(updateSchedule) - } - - browserHost.host.application.ticker.add(tick) - - return { - async destroy() { - browserHost.host.application.ticker.remove(tick) - await browserHost.destroy() - } - } -} diff --git a/examples/platformer/math.ts b/examples/platformer/math.ts deleted file mode 100644 index 6c60c19..0000000 --- a/examples/platformer/math.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { - AIR_ACCELERATION, - GROUND_ACCELERATION, - GROUND_DECELERATION, - RUN_SPEED, - WALK_SPEED -} from "./constants.ts" -import type { - CollisionBody, - HorizontalCollisionResult, - InputStateValue, - VerticalCollisionResult, - Vector2 -} from "./types.ts" - -const leftOf = (body: CollisionBody): number => body.position.x - body.collider.width * 0.5 -const rightOf = (body: CollisionBody): number => body.position.x + body.collider.width * 0.5 -const topOf = (body: CollisionBody): number => body.position.y - body.collider.height * 0.5 -const bottomOf = (body: CollisionBody): number => body.position.y + body.collider.height * 0.5 - -export const clamp = (value: number, min: number, max: number): number => - Math.min(Math.max(value, min), max) - -export const approachScalar = (current: number, target: number, maxDelta: number): number => { - if (current < target) { - return Math.min(current + maxDelta, target) - } - - return Math.max(current - maxDelta, target) -} - -export const horizontalIntent = (input: InputStateValue): -1 | 0 | 1 => { - if (input.left === input.right) { - return 0 - } - - return input.left ? -1 : 1 -} - -export const targetHorizontalSpeed = (input: InputStateValue): number => - horizontalIntent(input) * (input.runPressed ? RUN_SPEED : WALK_SPEED) - -export const resolveHorizontalSpeed = ( - currentVelocityX: number, - input: InputStateValue, - dt: number, - grounded: boolean -): number => { - const target = targetHorizontalSpeed(input) - const acceleration = - target === 0 && grounded ? GROUND_DECELERATION - : grounded ? GROUND_ACCELERATION - : AIR_ACCELERATION - - return approachScalar(currentVelocityX, target, acceleration * dt) -} - -const overlapsVertically = ( - movingPosition: Vector2, - movingCollider: { width: number; height: number }, - solid: CollisionBody -): boolean => { - const movingTop = movingPosition.y - movingCollider.height * 0.5 - const movingBottom = movingPosition.y + movingCollider.height * 0.5 - return movingBottom > topOf(solid) && movingTop < bottomOf(solid) -} - -const overlapsHorizontally = ( - movingPosition: Vector2, - movingCollider: { width: number; height: number }, - solid: CollisionBody -): boolean => { - const movingLeft = movingPosition.x - movingCollider.width * 0.5 - const movingRight = movingPosition.x + movingCollider.width * 0.5 - return movingRight > leftOf(solid) && movingLeft < rightOf(solid) -} - -export const resolveHorizontalMovement = ( - position: Vector2, - deltaX: number, - collider: { width: number; height: number }, - solids: ReadonlyArray -): HorizontalCollisionResult => { - if (deltaX === 0) { - return { - nextX: position.x, - blockedLeft: false, - blockedRight: false - } - } - - let nextX = position.x + deltaX - let blockedLeft = false - let blockedRight = false - - for (const solid of solids) { - if (!overlapsVertically(position, collider, solid)) { - continue - } - - if (deltaX > 0) { - const movingRightNow = position.x + collider.width * 0.5 - const movingRightNext = nextX + collider.width * 0.5 - const solidLeft = leftOf(solid) - if (movingRightNow <= solidLeft && movingRightNext > solidLeft) { - nextX = Math.min(nextX, solidLeft - collider.width * 0.5) - blockedRight = true - } - continue - } - - const movingLeftNow = position.x - collider.width * 0.5 - const movingLeftNext = nextX - collider.width * 0.5 - const solidRight = rightOf(solid) - if (movingLeftNow >= solidRight && movingLeftNext < solidRight) { - nextX = Math.max(nextX, solidRight + collider.width * 0.5) - blockedLeft = true - } - } - - return { - nextX: nextX as HorizontalCollisionResult["nextX"], - blockedLeft, - blockedRight - } -} - -export const resolveVerticalMovement = ( - position: Vector2, - deltaY: number, - collider: { width: number; height: number }, - solids: ReadonlyArray -): VerticalCollisionResult => { - if (deltaY === 0) { - return { - nextY: position.y, - grounded: false, - hitCeiling: false - } - } - - let nextY = position.y + deltaY - let grounded = false - let hitCeiling = false - - for (const solid of solids) { - if (!overlapsHorizontally(position, collider, solid)) { - continue - } - - if (deltaY > 0) { - const movingBottomNow = position.y + collider.height * 0.5 - const movingBottomNext = nextY + collider.height * 0.5 - const solidTop = topOf(solid) - if (movingBottomNow <= solidTop && movingBottomNext > solidTop) { - nextY = Math.min(nextY, solidTop - collider.height * 0.5) - grounded = true - } - continue - } - - const movingTopNow = position.y - collider.height * 0.5 - const movingTopNext = nextY - collider.height * 0.5 - const solidBottom = bottomOf(solid) - if (movingTopNow >= solidBottom && movingTopNext < solidBottom) { - nextY = Math.max(nextY, solidBottom + collider.height * 0.5) - hitCeiling = true - } - } - - return { - nextY: nextY as VerticalCollisionResult["nextY"], - grounded, - hitCeiling - } -} diff --git a/examples/platformer/package.json b/examples/platformer/package.json deleted file mode 100644 index 5ed7079..0000000 --- a/examples/platformer/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "@typeonce/ritmo-example-platformer", - "private": true, - "type": "module", - "dependencies": { - "@typeonce/ritmo": "workspace:*", - "@typeonce/ritmo-browser": "workspace:*", - "@typeonce/ritmo-pixi": "workspace:*", - "@typeonce/ritmo-render": "workspace:*", - "@typeonce/ritmo-math": "workspace:*" - } -} diff --git a/examples/platformer/queries.ts b/examples/platformer/queries.ts deleted file mode 100644 index 2e0f014..0000000 --- a/examples/platformer/queries.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Collider, Game, Player, Position, Solid, Velocity } from "./schema.ts" - -export const PlayerMovementQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.write(Velocity), - collider: Game.Query.read(Collider), - player: Game.Query.read(Player) - } -}) - -export const PlayerReadQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - velocity: Game.Query.read(Velocity), - collider: Game.Query.read(Collider), - player: Game.Query.read(Player) - } -}) - -export const PlayerCameraQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - player: Game.Query.read(Player) - } -}) - -export const SolidCollisionQuery = Game.Query({ - selection: { - position: Game.Query.read(Position), - collider: Game.Query.read(Collider), - solid: Game.Query.read(Solid) - } -}) diff --git a/examples/platformer/render/backdrop.ts b/examples/platformer/render/backdrop.ts deleted file mode 100644 index 1292114..0000000 --- a/examples/platformer/render/backdrop.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { Container, Graphics } from "pixi.js" - -import { WORLD_HEIGHT, WORLD_WIDTH } from "../constants.ts" - -export const createWorldBackdrop = (): Container => { - const root = new Container() - - const sky = new Graphics() - sky.rect(0, 0, WORLD_WIDTH, WORLD_HEIGHT) - sky.fill(0x6dc8ff) - - const cloudA = new Graphics() - cloudA.roundRect(220, 120, 220, 64, 26) - cloudA.fill({ color: 0xffffff, alpha: 0.75 }) - cloudA.roundRect(340, 92, 150, 58, 24) - cloudA.fill({ color: 0xffffff, alpha: 0.82 }) - - const cloudB = new Graphics() - cloudB.roundRect(1180, 160, 210, 56, 24) - cloudB.fill({ color: 0xffffff, alpha: 0.72 }) - cloudB.roundRect(1288, 132, 136, 52, 22) - cloudB.fill({ color: 0xffffff, alpha: 0.8 }) - - const hillBack = new Graphics() - hillBack.ellipse(340, WORLD_HEIGHT - 90, 210, 150) - hillBack.fill(0x61bc67) - hillBack.ellipse(860, WORLD_HEIGHT - 110, 280, 165) - hillBack.fill(0x61bc67) - hillBack.ellipse(1710, WORLD_HEIGHT - 100, 250, 158) - hillBack.fill(0x61bc67) - - const hillFront = new Graphics() - hillFront.ellipse(520, WORLD_HEIGHT - 58, 250, 110) - hillFront.fill(0x44a84b) - hillFront.ellipse(1350, WORLD_HEIGHT - 48, 320, 120) - hillFront.fill(0x44a84b) - hillFront.ellipse(2070, WORLD_HEIGHT - 56, 180, 100) - hillFront.fill(0x44a84b) - - root.addChild(sky) - root.addChild(cloudA) - root.addChild(cloudB) - root.addChild(hillBack) - root.addChild(hillFront) - return root -} diff --git a/examples/platformer/render/hud.ts b/examples/platformer/render/hud.ts deleted file mode 100644 index 8fc9df7..0000000 --- a/examples/platformer/render/hud.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { HudRefs } from "../types.ts" - -export const createHud = (): { - root: HTMLElement - refs: HudRefs -} => { - const root = document.createElement("section") - root.className = "platformer-hud" - - const panel = document.createElement("section") - panel.className = "platformer-hud__panel" - - const badge = document.createElement("div") - badge.className = "platformer-hud__badge" - badge.textContent = "ritmo platformer v1" - - const title = document.createElement("h1") - title.className = "platformer-hud__title" - title.textContent = "Platform run" - - const prompt = document.createElement("p") - prompt.className = "platformer-hud__prompt" - - const stats = document.createElement("p") - stats.className = "platformer-hud__stats" - - const hint = document.createElement("p") - hint.className = "platformer-hud__hint" - - panel.appendChild(badge) - panel.appendChild(title) - panel.appendChild(prompt) - panel.appendChild(stats) - panel.appendChild(hint) - - const overlay = document.createElement("section") - overlay.className = "platformer-overlay" - - const overlayTitle = document.createElement("h2") - overlayTitle.className = "platformer-overlay__title" - - const overlaySubtitle = document.createElement("p") - overlaySubtitle.className = "platformer-overlay__subtitle" - - const overlayHint = document.createElement("p") - overlayHint.className = "platformer-overlay__hint" - - overlay.appendChild(overlayTitle) - overlay.appendChild(overlaySubtitle) - overlay.appendChild(overlayHint) - - root.appendChild(panel) - root.appendChild(overlay) - - return { - root, - refs: { - prompt, - stats, - hint, - overlay, - overlayTitle, - overlaySubtitle, - overlayHint - } - } -} diff --git a/examples/platformer/render/nodes.ts b/examples/platformer/render/nodes.ts deleted file mode 100644 index 386df26..0000000 --- a/examples/platformer/render/nodes.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { Container, Graphics } from "pixi.js" - - -type AnyRenderable = { - kind: "player" | "ground" | "block" | "pipe" - width: number - height: number - color: number - accent: number -} - -type SolidRenderable = { - kind: "ground" | "block" | "pipe" - width: number - height: number - color: number - accent: number -} - -const createPlayerNode = (renderable: { - width: number - height: number - color: number - accent: number -}): Container => { - const node = new Container() - - const body = new Graphics() - body.roundRect( - -renderable.width * 0.5, - -renderable.height * 0.5, - renderable.width, - renderable.height, - 12 - ) - body.fill(renderable.color) - body.stroke({ - color: 0x3c1c1b, - width: 3 - }) - - const cap = new Graphics() - cap.roundRect( - -renderable.width * 0.5, - -renderable.height * 0.5, - renderable.width, - renderable.height * 0.28, - 10 - ) - cap.fill(renderable.accent) - - const eyes = new Graphics() - eyes.circle(-8, -6, 3) - eyes.circle(8, -6, 3) - eyes.fill(0x16202b) - - node.addChild(body) - node.addChild(cap) - node.addChild(eyes) - return node -} - -const createSolidNode = (renderable: { - kind: "ground" | "block" | "pipe" - width: number - height: number - color: number - accent: number -}): Container => { - const node = new Container() - const body = new Graphics() - - if (renderable.kind === "pipe") { - body.roundRect( - -renderable.width * 0.5, - -renderable.height * 0.5, - renderable.width, - renderable.height, - 10 - ) - body.fill(renderable.color) - body.stroke({ - color: renderable.accent, - width: 3, - alpha: 0.55 - }) - - const lip = new Graphics() - lip.roundRect( - -renderable.width * 0.5 - 10, - -renderable.height * 0.5, - renderable.width + 20, - 28, - 10 - ) - lip.fill(0x34a66d) - lip.stroke({ - color: renderable.accent, - width: 3 - }) - - node.addChild(body) - node.addChild(lip) - return node - } - - body.roundRect( - -renderable.width * 0.5, - -renderable.height * 0.5, - renderable.width, - renderable.height, - renderable.kind === "ground" ? 14 : 8 - ) - body.fill(renderable.color) - body.stroke({ - color: renderable.accent, - width: 2.5, - alpha: 0.48 - }) - - const trim = new Graphics() - trim.rect( - -renderable.width * 0.5 + 10, - -renderable.height * 0.5 + 10, - Math.max(renderable.width - 20, 0), - Math.max(renderable.height - 20, 0) - ) - trim.stroke({ - color: 0x4b2b1e, - width: 2, - alpha: 0.28 - }) - - node.addChild(body) - node.addChild(trim) - return node -} - -/** - * Builds the Pixi node for one renderable; `RenderSync` attaches it. - */ -export const createRenderNode = (renderable: Readonly): Container => - renderable.kind === "player" - ? createPlayerNode(renderable) - : createSolidNode({ - kind: renderable.kind, - width: renderable.width, - height: renderable.height, - color: renderable.color, - accent: renderable.accent - }) diff --git a/examples/platformer/runtime.ts b/examples/platformer/runtime.ts deleted file mode 100644 index 732c895..0000000 --- a/examples/platformer/runtime.ts +++ /dev/null @@ -1,67 +0,0 @@ -import * as Result from "@typeonce/ritmo/Result" -import { levelBounds } from "./content.ts" -import { Camera, DeltaTime, Game, InputManager, InputState, LoseMessage, PlatformerHost, PlayerContacts, SessionState, Viewport, - RenderNodes -} from "./schema.ts" -import type { InputStateValue, PlatformerHostValue, PlatformerInputManager, PlayerContactsValue } from "./types.ts" - -export const makeEmptyInputState = (): InputStateValue => ({ - left: false, - right: false, - jumpPressed: false, - jumpJustPressed: false, - runPressed: false, - restartJustPressed: false -}) - -export const makeInitialPlayerContacts = (): PlayerContactsValue => ({ - grounded: false, - hitCeiling: false, - blockedLeft: false, - blockedRight: false -}) - -const makeRuntime = ( - host: PlatformerHostValue, - inputManager: PlatformerInputManager -) => - Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(InputManager, inputManager), - Game.Runtime.service(PlatformerHost, host), - Game.Runtime.service(RenderNodes, host.nodes) - ), - resources: { - DeltaTime: host.clock.deltaSeconds, - Viewport: { - width: host.application.screen.width, - height: host.application.screen.height - }, - Camera: { - x: Math.min(host.application.screen.width * 0.5, levelBounds.width * 0.5), - y: Math.min(host.application.screen.height * 0.5, levelBounds.height * 0.5) - }, - InputState: makeEmptyInputState(), - PlayerContacts: makeInitialPlayerContacts(), - LoseMessage: "You fell into a hole." - }, - machines: Game.Runtime.machines( - Game.Runtime.machine(SessionState, "Playing") - ) - }) - -export const createPlatformerRuntime = ( - host: PlatformerHostValue, - inputManager: PlatformerInputManager -) => - Result.match(makeRuntime(host, inputManager), { - onSuccess: Result.success, - onFailure: (error) => - Result.failure({ - message: error.resources.Viewport - ? "Invalid platformer viewport." - : error.resources.Camera - ? "Invalid platformer camera." - : "Invalid platformer runtime resources." - }) - }) diff --git a/examples/platformer/schedules.ts b/examples/platformer/schedules.ts deleted file mode 100644 index 2f7efac..0000000 --- a/examples/platformer/schedules.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - ApplyGravitySystem, - ApplyJumpSystem, - ApplyWorldCameraTransformSystem, - CaptureFrameContextSystem, - RenderNodesSystem, - MovePlayerSystem, - QueueLossSystem, - QueueRestartSystem, - ResetWorldResourcesOnPlayingEnterSystem, - ResolveMoveIntentSystem, - SetupWorldSystem, - SpawnWorldOnPlayingEnterSystem, - SyncCameraSystem, - SyncHudSystem -} from "./systems/index.ts" -import { Game, SessionState } from "./schema.ts" - -export const setupSchedule = Game.Schedule( - SetupWorldSystem, - Game.Schedule.applyDeferred(), - SyncCameraSystem, - ApplyWorldCameraTransformSystem, - RenderNodesSystem, - SyncHudSystem -) - -// LevelScope despawns itself on entering Playing, before this runs. -const restartOnPlayingEnter = Game.Schedule( - ResetWorldResourcesOnPlayingEnterSystem, - SpawnWorldOnPlayingEnterSystem -) - -export const stateTransitions = Game.Schedule.transitions( - Game.Schedule.onEnter(SessionState, "Playing", [ - restartOnPlayingEnter - ]) -) - -export const updateSchedule = Game.Schedule( - CaptureFrameContextSystem, - ResolveMoveIntentSystem, - ApplyJumpSystem, - ApplyGravitySystem, - MovePlayerSystem, - QueueLossSystem, - QueueRestartSystem, - Game.Schedule.applyStateTransitions(stateTransitions), - Game.Schedule.applyDeferred(), - SyncCameraSystem, - ApplyWorldCameraTransformSystem, - RenderNodesSystem, - SyncHudSystem -) diff --git a/examples/platformer/schema.ts b/examples/platformer/schema.ts deleted file mode 100644 index 3145c9f..0000000 --- a/examples/platformer/schema.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Descriptor, Schema } from "@typeonce/ritmo" -import * as Size2 from "@typeonce/ritmo-math/Size2" -import * as Vector2 from "@typeonce/ritmo-math/Vector2" -import type { InputStateValue, PlatformerHostValue, PlayerContactsValue } from "./types.ts" - -export const Position = Descriptor.ConstructedComponent(Vector2)("Platformer/Position") -export const Velocity = Descriptor.ConstructedComponent(Vector2)("Platformer/Velocity") -export const Collider = Descriptor.ConstructedComponent(Size2)("Platformer/Collider") -export const Renderable = Descriptor.Component<{ - kind: "player" | "ground" | "block" | "pipe" - width: number - height: number - color: number - accent: number -}>()("Platformer/Renderable") -export const Player = Descriptor.Component<{}>()("Platformer/Player") -export const Solid = Descriptor.Component<{}>()("Platformer/Solid") - -export const DeltaTime = Descriptor.Resource()("Platformer/DeltaTime") -export const Viewport = Descriptor.ConstructedResource(Size2)("Platformer/Viewport") -export const Camera = Descriptor.ConstructedResource(Vector2)("Platformer/Camera") -export const InputState = Descriptor.Resource()("Platformer/InputState") -export const PlayerContacts = Descriptor.Resource()("Platformer/PlayerContacts") -export const LoseMessage = Descriptor.Resource()("Platformer/LoseMessage") - -export const InputManager = Descriptor.Service<{ - readonly snapshot: () => InputStateValue -}>()("Platformer/InputManager") -export const PlatformerHost = Descriptor.Service()("Platformer/Host") -export const RenderNodes = Descriptor.Service()("Platformer/RenderNodes") - -export const Game = Schema.bind( - Schema.fragment({ - components: { - Position, - Velocity, - Collider, - Renderable, - Player, - Solid - }, - requires: [ - Schema.requires(Player, Position, Velocity, Collider, Renderable), - Schema.requires(Solid, Position, Collider, Renderable) - ], - resources: { - DeltaTime, - Viewport, - Camera, - InputState, - PlayerContacts, - LoseMessage - } - }) -) - -export const schema = Game.schema - -export const SessionState = Game.StateMachine( - "Platformer/SessionState", - ["Playing", "Lost"] as const -) - -/** The level's player and solids: despawned on each restart, before the new level spawns. */ -export const LevelScope = Game.EntityScope("Platformer/Level", { - despawnOnEnter: [SessionState, "Playing"] -}) diff --git a/examples/platformer/systems/camera.ts b/examples/platformer/systems/camera.ts deleted file mode 100644 index f87441c..0000000 --- a/examples/platformer/systems/camera.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { levelBounds } from "../content.ts" -import { PlayerCameraQuery } from "../queries.ts" -import { Camera, Game, PlatformerHost, Viewport } from "../schema.ts" -import { clamp } from "../math.ts" - -export const SyncCameraSystem = Game.System( - "Platformer/SyncCamera", - { - queries: { - player: PlayerCameraQuery - }, - resources: { - camera: Game.System.writeResource(Camera), - viewport: Game.System.readResource(Viewport) - } - }, - ({ queries, resources }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const viewport = resources.viewport.get() - const position = player.value.data.position.get() - - resources.camera.setRaw({ - x: clamp(position.x, viewport.width * 0.5, levelBounds.width - viewport.width * 0.5), - y: clamp(position.y - 96, viewport.height * 0.5, levelBounds.height - viewport.height * 0.5) - }) - } -) - -export const ApplyWorldCameraTransformSystem = Game.System( - "Platformer/ApplyWorldCameraTransform", - { - resources: { - viewport: Game.System.readResource(Viewport), - camera: Game.System.readResource(Camera) - }, - services: { - host: Game.System.service(PlatformerHost) - } - }, - ({ resources, services }) => - { - services.host.world.position.set( - resources.viewport.get().width * 0.5 - resources.camera.get().x, - resources.viewport.get().height * 0.5 - resources.camera.get().y - ) - } -) diff --git a/examples/platformer/systems/hud.ts b/examples/platformer/systems/hud.ts deleted file mode 100644 index f947793..0000000 --- a/examples/platformer/systems/hud.ts +++ /dev/null @@ -1,62 +0,0 @@ - -import { levelBounds } from "../content.ts" -import { PlayerReadQuery } from "../queries.ts" -import { Game, LoseMessage, PlatformerHost, PlayerContacts, SessionState } from "../schema.ts" - -export const SyncHudSystem = Game.System( - "Platformer/SyncHud", - { - queries: { - player: PlayerReadQuery - }, - resources: { - contacts: Game.System.changedResource(PlayerContacts), - loseMessage: Game.System.changedResource(LoseMessage) - }, - machines: { - session: Game.System.machine(SessionState) - }, - transitionEvents: { - session: Game.System.readTransitionEvent(SessionState) - }, - services: { - host: Game.System.service(PlatformerHost) - } - }, - ({ queries, resources, machines, transitionEvents, services }) => - { - const player = queries.player.singleOptional() - const hud = services.host.hud - - // The position moves every frame; the other labels change only with what they show. - if (player.ok && player.value) { - const position = player.value.data.position.get() - const velocity = player.value.data.velocity.get() - hud.stats.textContent = - `x ${Math.round(position.x)} | y ${Math.round(position.y)} | vx ${Math.round(velocity.x)} | vy ${Math.round(velocity.y)}` - } - if (resources.contacts.changed()) { - const contacts = resources.contacts.get() - hud.hint.textContent = - `Grounded ${contacts.grounded ? "yes" : "no"} | Ceiling ${contacts.hitCeiling ? "yes" : "no"} | Kill plane ${levelBounds.killPlaneY}` - } - - if (transitionEvents.session.all().length === 0 && !resources.loseMessage.changed()) return - hud.prompt.textContent = - machines.session.get() === "Playing" - ? "Run with Shift, move with Arrow keys or A/D, jump with Space, W, or ArrowUp." - : resources.loseMessage.get() - if (machines.session.get() === "Lost") { - hud.overlay.style.opacity = "1" - hud.overlayTitle.textContent = "You lost" - hud.overlaySubtitle.textContent = "The hole wins this round." - hud.overlayHint.textContent = "Click anywhere or press Enter to restart from the spawn point." - return - } - - hud.overlay.style.opacity = "0" - hud.overlayTitle.textContent = "" - hud.overlaySubtitle.textContent = "" - hud.overlayHint.textContent = "" - } -) diff --git a/examples/platformer/systems/index.ts b/examples/platformer/systems/index.ts deleted file mode 100644 index 9306757..0000000 --- a/examples/platformer/systems/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { SyncCameraSystem, ApplyWorldCameraTransformSystem } from "./camera.ts" -export { SyncHudSystem } from "./hud.ts" -export { CaptureFrameContextSystem } from "./input.ts" -export { - ApplyGravitySystem, - ApplyJumpSystem, - MovePlayerSystem, - ResolveMoveIntentSystem -} from "./movement.ts" -export { RenderNodesSystem } from "./render-sync.ts" -export { SetupWorldSystem } from "./setup.ts" -export { - QueueLossSystem, - QueueRestartSystem, - ResetWorldResourcesOnPlayingEnterSystem, - SpawnWorldOnPlayingEnterSystem -} from "./state.ts" diff --git a/examples/platformer/systems/input.ts b/examples/platformer/systems/input.ts deleted file mode 100644 index b79404c..0000000 --- a/examples/platformer/systems/input.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { DeltaTime, Game, InputManager, InputState, PlatformerHost, Viewport } from "../schema.ts" - -export const CaptureFrameContextSystem = Game.System( - "Platformer/CaptureFrameContext", - { - resources: { - deltaTime: Game.System.writeResource(DeltaTime), - viewport: Game.System.writeResource(Viewport), - input: Game.System.writeResource(InputState) - }, - services: { - host: Game.System.service(PlatformerHost), - input: Game.System.service(InputManager) - } - }, - ({ resources, services }) => - { - resources.deltaTime.set(services.host.clock.deltaSeconds) - resources.viewport.setRaw({ - width: services.host.application.screen.width, - height: services.host.application.screen.height - }) - resources.input.set(services.input.snapshot()) - } -) diff --git a/examples/platformer/systems/movement.ts b/examples/platformer/systems/movement.ts deleted file mode 100644 index cbb78cd..0000000 --- a/examples/platformer/systems/movement.ts +++ /dev/null @@ -1,173 +0,0 @@ -import * as Vector2 from "@typeonce/ritmo-math/Vector2" -import { GRAVITY, JUMP_VELOCITY, MAX_FALL_SPEED } from "../constants.ts" -import { PlayerMovementQuery, SolidCollisionQuery } from "../queries.ts" -import { DeltaTime, Game, InputState, PlayerContacts } from "../schema.ts" -import { clamp, resolveHorizontalMovement, resolveHorizontalSpeed, resolveVerticalMovement } from "../math.ts" - -export const ResolveMoveIntentSystem = Game.System( - "Platformer/ResolveMoveIntent", - { - queries: { - player: PlayerMovementQuery - }, - resources: { - input: Game.System.readResource(InputState), - deltaTime: Game.System.readResource(DeltaTime), - contacts: Game.System.readResource(PlayerContacts) - } - }, - ({ queries, resources }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const nextVelocityX = resolveHorizontalSpeed( - player.value.data.velocity.get().x, - resources.input.get(), - resources.deltaTime.get(), - resources.contacts.get().grounded - ) - - player.value.data.velocity.updateRaw((velocity) => - ({ - x: nextVelocityX, - y: velocity.y - }) - ) - } -) - -export const ApplyJumpSystem = Game.System( - "Platformer/ApplyJump", - { - queries: { - player: PlayerMovementQuery - }, - resources: { - input: Game.System.readResource(InputState), - contacts: Game.System.writeResource(PlayerContacts) - } - }, - ({ queries, resources }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const input = resources.input.get() - const contacts = resources.contacts.get() - if (!contacts.grounded || !input.jumpJustPressed) { - return - } - - player.value.data.velocity.updateRaw((velocity) => - ({ - x: velocity.x, - y: -JUMP_VELOCITY - }) - ) - - resources.contacts.set({ - grounded: false, - hitCeiling: false, - blockedLeft: contacts.blockedLeft, - blockedRight: contacts.blockedRight - }) - } -) - -export const ApplyGravitySystem = Game.System( - "Platformer/ApplyGravity", - { - queries: { - player: PlayerMovementQuery - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime) - } - }, - ({ queries, resources }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - player.value.data.velocity.updateRaw((velocity) => - ({ - x: velocity.x, - y: clamp( - velocity.y + GRAVITY * resources.deltaTime.get(), - -JUMP_VELOCITY, - MAX_FALL_SPEED - ) - }) - ) - } -) - -export const MovePlayerSystem = Game.System( - "Platformer/MovePlayer", - { - queries: { - player: PlayerMovementQuery, - solids: SolidCollisionQuery - }, - resources: { - deltaTime: Game.System.readResource(DeltaTime), - contacts: Game.System.writeResource(PlayerContacts) - } - }, - ({ queries, resources }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const dt = resources.deltaTime.get() - const position = player.value.data.position.get() - const velocity = player.value.data.velocity.get() - const collider = player.value.data.collider.get() - const solids = queries.solids.each().map((match) => ({ - position: match.data.position.get(), - collider: match.data.collider.get() - })) - - const horizontalResult = resolveHorizontalMovement(position, velocity.x * dt, collider, solids) - const horizontalPosition = Vector2.result({ - x: horizontalResult.nextX, - y: position.y - }) - if (!horizontalPosition.ok) { - return - } - const verticalResult = resolveVerticalMovement(horizontalPosition.value, velocity.y * dt, collider, solids) - - player.value.data.position.setRaw({ - x: horizontalResult.nextX, - y: verticalResult.nextY - }) - - player.value.data.velocity.setRaw({ - x: - horizontalResult.blockedLeft || horizontalResult.blockedRight - ? 0 - : velocity.x, - y: - verticalResult.grounded || verticalResult.hitCeiling - ? 0 - : velocity.y - }) - - resources.contacts.set({ - grounded: verticalResult.grounded, - hitCeiling: verticalResult.hitCeiling, - blockedLeft: horizontalResult.blockedLeft, - blockedRight: horizontalResult.blockedRight - }) - } -) diff --git a/examples/platformer/systems/render-sync.ts b/examples/platformer/systems/render-sync.ts deleted file mode 100644 index 0e480e9..0000000 --- a/examples/platformer/systems/render-sync.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { RenderSync } from "@typeonce/ritmo-render" - -import { createRenderNode } from "../render/nodes.ts" -import { Game, Position, RenderNodes, Renderable } from "../schema.ts" - -const render = RenderSync.system(Game, { - name: "Platformer/Render", - renderable: Renderable, - transform: Position, - registry: RenderNodes, - create: ({ renderable }) => createRenderNode(renderable), - apply: (node, { transform }) => { - node.position.set(transform.x, transform.y) - } -}) - -export const RenderNodesSystem = render diff --git a/examples/platformer/systems/setup.ts b/examples/platformer/systems/setup.ts deleted file mode 100644 index 065f3b3..0000000 --- a/examples/platformer/systems/setup.ts +++ /dev/null @@ -1,32 +0,0 @@ - -import { levelSolids } from "../content.ts" -import { playerDraft, makeSolidDraft } from "../drafts.ts" -import { Game, LevelScope, LoseMessage, Player, PlayerContacts, Solid } from "../schema.ts" -import { makeInitialPlayerContacts } from "../runtime.ts" - -export const SetupWorldSystem = Game.System( - "Platformer/SetupWorld", - { - resources: { - contacts: Game.System.writeResource(PlayerContacts), - loseMessage: Game.System.writeResource(LoseMessage) - }, - commands: Game.System.commands(Game.System.spawns(Player, Solid)) - }, - ({ commands, resources }) => - { - resources.contacts.set(makeInitialPlayerContacts()) - resources.loseMessage.set("You fell into a hole.") - - if (playerDraft.ok) { - commands.spawnIn(LevelScope, playerDraft.value) - } - - for (const solid of levelSolids) { - const solidDraft = makeSolidDraft(solid) - if (solidDraft.ok) { - commands.spawnIn(LevelScope, solidDraft.value) - } - } - } -) diff --git a/examples/platformer/systems/state.ts b/examples/platformer/systems/state.ts deleted file mode 100644 index 6dbcffe..0000000 --- a/examples/platformer/systems/state.ts +++ /dev/null @@ -1,91 +0,0 @@ - -import { levelBounds, levelSolids } from "../content.ts" -import { playerDraft, makeSolidDraft } from "../drafts.ts" -import { PlayerReadQuery } from "../queries.ts" -import { Game, InputState, LevelScope, LoseMessage, Player, PlayerContacts, SessionState, Solid } from "../schema.ts" -import { makeInitialPlayerContacts } from "../runtime.ts" - -export const QueueLossSystem = Game.System( - "Platformer/QueueLoss", - { - when: [Game.Condition.inState(SessionState, "Playing")], - queries: { - player: PlayerReadQuery - }, - resources: { - loseMessage: Game.System.writeResource(LoseMessage) - }, - nextMachines: { - session: Game.System.nextState(SessionState) - } - }, - ({ queries, resources, nextMachines }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const position = player.value.data.position.get() - if (position.y <= levelBounds.killPlaneY) { - return - } - - resources.loseMessage.set("You fell into a hole. Click anywhere or press Enter to restart.") - nextMachines.session.set("Lost") - } -) - -export const QueueRestartSystem = Game.System( - "Platformer/QueueRestart", - { - when: [Game.Condition.inState(SessionState, "Lost")], - resources: { - input: Game.System.readResource(InputState) - }, - nextMachines: { - session: Game.System.nextState(SessionState) - } - }, - ({ resources, nextMachines }) => - { - if (resources.input.get().restartJustPressed) { - nextMachines.session.set("Playing") - } - } -) - -export const ResetWorldResourcesOnPlayingEnterSystem = Game.System( - "Platformer/ResetWorldResourcesOnPlayingEnter", - { - resources: { - contacts: Game.System.writeResource(PlayerContacts), - loseMessage: Game.System.writeResource(LoseMessage) - } - }, - ({ resources }) => - { - resources.contacts.set(makeInitialPlayerContacts()) - resources.loseMessage.set("You fell into a hole.") - } -) - -export const SpawnWorldOnPlayingEnterSystem = Game.System( - "Platformer/SpawnWorldOnPlayingEnter", - { - commands: Game.System.commands(Game.System.spawns(Player, Solid)) - }, - ({ commands }) => - { - if (playerDraft.ok) { - commands.spawnIn(LevelScope, playerDraft.value) - } - - for (const solid of levelSolids) { - const solidDraft = makeSolidDraft(solid) - if (solidDraft.ok) { - commands.spawnIn(LevelScope, solidDraft.value) - } - } - } -) diff --git a/examples/platformer/tsconfig.json b/examples/platformer/tsconfig.json deleted file mode 100644 index eca7f23..0000000 --- a/examples/platformer/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["./**/*.ts"] -} diff --git a/examples/platformer/types.ts b/examples/platformer/types.ts deleted file mode 100644 index d75042c..0000000 --- a/examples/platformer/types.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { NodeRegistry } from "@typeonce/ritmo-render" -import type { Application, Container } from "pixi.js" -import type * as Scalar from "@typeonce/ritmo-math/Scalar" -import type * as Size2Value from "@typeonce/ritmo-math/Size2" -import type * as Vector2Value from "@typeonce/ritmo-math/Vector2" - -export type Vector2 = Vector2Value.Vector2 -export type Size2 = Size2Value.Size2 - -export type InputStateValue = { - left: boolean - right: boolean - jumpPressed: boolean - jumpJustPressed: boolean - runPressed: boolean - restartJustPressed: boolean -} - -export type PlayerContactsValue = { - grounded: boolean - hitCeiling: boolean - blockedLeft: boolean - blockedRight: boolean -} - -export type HudRefs = { - prompt: HTMLElement - stats: HTMLElement - hint: HTMLElement - overlay: HTMLElement - overlayTitle: HTMLElement - overlaySubtitle: HTMLElement - overlayHint: HTMLElement -} - -export type PlatformerHostValue = { - application: Application - world: Container - actorLayer: Container - nodes: NodeRegistry.NodeRegistry - hud: HudRefs - clock: { - deltaSeconds: number - } -} - -export type PlatformerInputManager = { - readonly snapshot: () => InputStateValue -} - -export type CollisionBody = { - position: Vector2 - collider: Size2 -} - -export type HorizontalCollisionResult = { - nextX: Scalar.Finite - blockedLeft: boolean - blockedRight: boolean -} - -export type VerticalCollisionResult = { - nextY: Scalar.Finite - grounded: boolean - hitCeiling: boolean -} diff --git a/examples/smoke.ts b/examples/smoke.ts deleted file mode 100644 index e3cbb0a..0000000 --- a/examples/smoke.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * Compact smoke example for the current public ECS surface. - * - * It keeps the scenario intentionally small while exercising the modern API: - * schema binding, machine-gated systems, deferred commands, explicit event - * visibility, runtime provisioning, and runtime ticks. - */ -import { Descriptor, Schema } from "@typeonce/ritmo" - -const Position = Descriptor.Component<{ x: number; y: number }>()("Smoke/Position") -const Velocity = Descriptor.Component<{ x: number; y: number }>()("Smoke/Velocity") - -const Time = Descriptor.Resource()("Smoke/Time") -const TickCount = Descriptor.Resource()("Smoke/TickCount") - -const TickEvent = Descriptor.Event<{ - readonly tick: number - readonly dt: number -}>()("Smoke/TickEvent") - -const Logger = Descriptor.Service<{ - readonly log: (message: string) => void -}>()("Smoke/Logger") - -const Game = Schema.bind(Schema.fragment({ - components: { - Position, - Velocity - }, - resources: { - Time, - TickCount - }, - events: { - TickEvent - } -})) -const schema = Game.schema -// `Phase` is a machine because the queued transition boundary matters for -// gameplay flow. A plain state descriptor would not model that boundary. -const Phase = Game.StateMachine("Smoke/Phase", ["Running", "Paused"]) - -const MovingQuery = Game.Query({ - selection: { - position: Game.Query.write(Position), - velocity: Game.Query.read(Velocity) - } -}) - -const SetupSystem = Game.System( - "Smoke/Setup", - { - commands: Game.System.commands(Game.System.spawns(Position, Velocity)) - }, - ({ commands }) => - { - commands.spawn( - Game.Command.spawn( - [Position, { x: 0, y: 0 }], - [Velocity, { x: 1, y: 0.5 }] - ) - ) - } -) - -const MoveSystem = Game.System( - "Smoke/Move", - { - when: [Game.Condition.inState(Phase, "Running")], - queries: { - moving: MovingQuery - }, - resources: { - time: Game.System.readResource(Time), - tickCount: Game.System.writeResource(TickCount) - }, - events: { - tick: Game.System.writeEvent(TickEvent) - }, - services: { - logger: Game.System.service(Logger) - }, - commands: Game.System.commands(Game.System.spawns(Position, Velocity)) - }, - ({ queries, resources, events, services, commands }) => - { - const dt = resources.time.get() - resources.tickCount.update((value) => value + 1) - const tick = resources.tickCount.get() - - for (const { data } of queries.moving.each()) { - const velocity = data.velocity.get() - data.position.update((position) => ({ - x: position.x + velocity.x * dt, - y: position.y + velocity.y * dt - })) - } - - commands.spawn( - Game.Command.spawn( - [Position, { x: tick * dt, y: tick * dt }], - [Velocity, { x: 1, y: 1 }] - ) - ) - - events.tick.emit({ tick, dt }) - services.logger.log(`queued tick=${tick}`) - } -) - -const ObserveTickSystem = Game.System( - "Smoke/ObserveTick", - { - events: { - tick: Game.System.readEvent(TickEvent) - }, - services: { - logger: Game.System.service(Logger) - } - }, - ({ events, services }) => - { - for (const event of events.tick.all()) { - services.logger.log(`observed tick=${event.tick} dt=${event.dt}`) - } - } -) - -const bootstrap = Game.Schedule(SetupSystem, Game.Schedule.applyDeferred()) - -const update = Game.Schedule( - MoveSystem, - Game.Schedule.applyDeferred(), - ObserveTickSystem -) - -const runtime = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(Logger, { - log(message) { - console.log(message) - } - }) - ), - resources: { - Time: 0.5, - TickCount: 0 - }, - machines: Game.Runtime.machines( - Game.Runtime.machine(Phase, "Running") - ) -}).value - -export const createSmokeExample = () => { - const setup = runtime.tick(bootstrap) - if (!setup.ok) throw new Error(`setup failed: ${JSON.stringify(setup.error)}`) - - return { - runtime, - updateSchedule: update - } -} diff --git a/examples/top-down/browser.ts b/examples/top-down/browser.ts deleted file mode 100644 index c6ef91c..0000000 --- a/examples/top-down/browser.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { startTopDownExample } from "./main.ts" - -const mount = document.querySelector("[data-top-down-root]") - -if (!mount) { - throw new Error("Missing [data-top-down-root] host element in examples/top-down/index.html") -} - -void startTopDownExample(mount) diff --git a/examples/top-down/constants.ts b/examples/top-down/constants.ts deleted file mode 100644 index acc2ee6..0000000 --- a/examples/top-down/constants.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Clock } from "@typeonce/ritmo" -import type { FacingValue } from "./types.ts" - -export const WORLD_WIDTH = 2200 -export const WORLD_HEIGHT = 1600 -export const PLAYER_SPEED = 280 -export const PLAYER_SIZE = 36 -export const PLAYER_INTERACT_RADIUS = 72 -export const PLAYER_FRAME_SECONDS = 0.1 -export const PLAYER_FRAME_SIZE = 16 -export const FIXED_STEP = Clock.hz(60) -export const FIXED_STEP_SECONDS = FIXED_STEP.dt -export const MAX_FRAME_SECONDS = 0.1 -export const MAX_STEPS_PER_FRAME = 5 -export const PLAYER_SHEET_URL = new URL("../../assets/spr_player.png", import.meta.url).href - -export const facingRows: Readonly> = { - Down: 1, - Left: 2, - Right: 3, - Up: 4 -} diff --git a/examples/top-down/host.ts b/examples/top-down/host.ts deleted file mode 100644 index 3c31a3e..0000000 --- a/examples/top-down/host.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { Keyboard } from "@typeonce/ritmo-browser" -import { NodeRegistry } from "@typeonce/ritmo-render" -import { Application, Assets, Container, Texture } from "pixi.js" - -import { PLAYER_SHEET_URL } from "./constants.ts" -import { createWorldBackdrop } from "./render/backdrop.ts" -import { createHud } from "./render/hud.ts" -import { destroyRenderNode } from "./render/nodes.ts" -import { createPlayerFrameAtlas } from "./render/player-sheet.ts" -import { inputBindings } from "./types.ts" -import type { KeyboardInput, RenderNode, TopDownHostValue } from "./types.ts" - -export type TopDownBrowserHost = { - readonly host: TopDownHostValue - readonly keyboard: KeyboardInput - destroy(): Promise -} - -export const createTopDownBrowserHost = async ( - mount: HTMLElement -): Promise => { - const [application, playerSheet] = await Promise.all([ - (async () => { - const app = new Application() - await app.init({ - antialias: true, - backgroundAlpha: 0, - resizeTo: mount - }) - return app - })(), - Assets.load(PLAYER_SHEET_URL) - ]) - - const shell = document.createElement("section") - shell.className = "top-down-shell" - - const viewport = document.createElement("div") - viewport.className = "top-down-shell__viewport" - viewport.appendChild(application.canvas) - - const hud = createHud() - shell.appendChild(viewport) - shell.appendChild(hud.root) - mount.replaceChildren(shell) - - const world = new Container() - const actorLayer = new Container() - world.addChild(createWorldBackdrop()) - world.addChild(actorLayer) - application.stage.addChild(world) - - const keyboard = Keyboard.actions(window, inputBindings) - - const host: TopDownHostValue = { - application, - world, - actorLayer, - nodes: NodeRegistry.make({ - attach: (renderNode) => { - actorLayer.addChild(renderNode.node) - }, - detach: (renderNode) => { - actorLayer.removeChild(renderNode.node) - destroyRenderNode(renderNode) - } - }), - playerFrames: createPlayerFrameAtlas(playerSheet), - hud: hud.refs, - clock: { - deltaSeconds: 1 / 60 - } - } - - return { - host, - keyboard, - async destroy() { - keyboard.dispose() - - host.nodes.clear() - application.destroy(true) - mount.replaceChildren() - } - } -} diff --git a/examples/top-down/index.html b/examples/top-down/index.html deleted file mode 100644 index 2eb249e..0000000 --- a/examples/top-down/index.html +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - ritmo Top-down Example - - - -
- - - diff --git a/examples/top-down/main.ts b/examples/top-down/main.ts deleted file mode 100644 index c6d45a7..0000000 --- a/examples/top-down/main.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { FixedLoop } from "@typeonce/ritmo" - -import { FIXED_STEP, MAX_FRAME_SECONDS, MAX_STEPS_PER_FRAME } from "./constants.ts" -import { createTopDownBrowserHost } from "./host.ts" -import { createTopDownRuntime } from "./runtime.ts" -import { setupSchedule, updateSchedule } from "./schedules.ts" - -interface BrowserExampleHandle { - destroy(): Promise -} - -const failedHandle = (): BrowserExampleHandle => ({ - async destroy() {} -}) - -export const startTopDownExample = async (mount: HTMLElement): Promise => { - const browserHost = await createTopDownBrowserHost(mount) - const runtime = createTopDownRuntime(browserHost.host, browserHost.keyboard) - if (!runtime.ok) { - await browserHost.destroy() - mount.textContent = runtime.error.message - return failedHandle() - } - runtime.value.tick(setupSchedule) - - const loop = FixedLoop.start({ - source: FixedLoop.source((onTick) => { - const tick = (ticker: { readonly deltaMS: number }) => onTick(ticker) - browserHost.host.application.ticker.add(tick) - return { - stop: () => browserHost.host.application.ticker.remove(tick) - } - }), - step: FIXED_STEP, - maxFrameSeconds: MAX_FRAME_SECONDS, - maxStepsPerFrame: MAX_STEPS_PER_FRAME, - update: (stepSeconds) => { - browserHost.host.clock.deltaSeconds = stepSeconds - return runtime.value.tick(updateSchedule) - }, - render: () => {}, - onFailure: (failure) => { - mount.textContent = failure.kind === "InvalidTickDelta" - ? `Invalid frame delta: ${failure.deltaMS}` - : "The game update stopped after an expected system failure." - } - }) - - if (!loop.ok) { - await browserHost.destroy() - mount.textContent = `Invalid fixed-loop option: ${loop.error.field}` - return failedHandle() - } - - return { - async destroy() { - loop.value.stop() - await browserHost.destroy() - } - } -} diff --git a/examples/top-down/render/backdrop.ts b/examples/top-down/render/backdrop.ts deleted file mode 100644 index abba284..0000000 --- a/examples/top-down/render/backdrop.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Graphics } from "pixi.js" - -import { WORLD_HEIGHT, WORLD_WIDTH } from "../constants.ts" - -export const createWorldBackdrop = (): Graphics => { - const backdrop = new Graphics() - - backdrop.rect(0, 0, WORLD_WIDTH, WORLD_HEIGHT) - backdrop.fill(0x0f1720) - - for (let x = 0; x <= WORLD_WIDTH; x += 120) { - backdrop.moveTo(x, 0) - backdrop.lineTo(x, WORLD_HEIGHT) - } - for (let y = 0; y <= WORLD_HEIGHT; y += 120) { - backdrop.moveTo(0, y) - backdrop.lineTo(WORLD_WIDTH, y) - } - backdrop.stroke({ - color: 0x1a2731, - width: 1 - }) - - backdrop.roundRect(14, 14, WORLD_WIDTH - 28, WORLD_HEIGHT - 28, 24) - backdrop.stroke({ - color: 0x2e4658, - width: 4 - }) - - return backdrop -} diff --git a/examples/top-down/render/hud.ts b/examples/top-down/render/hud.ts deleted file mode 100644 index 05576d0..0000000 --- a/examples/top-down/render/hud.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { HudRefs } from "../types.ts" - -export const createHud = (): { - root: HTMLElement - refs: HudRefs -} => { - const root = document.createElement("section") - root.className = "top-down-hud" - - const badge = document.createElement("div") - badge.className = "top-down-hud__badge" - badge.textContent = "ritmo proof of concept" - - const title = document.createElement("h1") - title.className = "top-down-hud__title" - title.textContent = "Top-down collection run" - - const prompt = document.createElement("p") - prompt.className = "top-down-hud__prompt" - - const stats = document.createElement("p") - stats.className = "top-down-hud__stats" - - const hint = document.createElement("p") - hint.className = "top-down-hud__hint" - - root.appendChild(badge) - root.appendChild(title) - root.appendChild(prompt) - root.appendChild(stats) - root.appendChild(hint) - - return { - root, - refs: { - prompt, - stats, - hint - } - } -} diff --git a/examples/top-down/render/nodes.ts b/examples/top-down/render/nodes.ts deleted file mode 100644 index 39eda34..0000000 --- a/examples/top-down/render/nodes.ts +++ /dev/null @@ -1,133 +0,0 @@ -import { Container, Graphics, Sprite, Texture } from "pixi.js" - -import type { - CurrentPlayerFrameValue, - PlayerFrameAtlas, - RenderNode -} from "../types.ts" - -const createPlayerNode = ( - renderable: { - width: number - height: number - }, - texture: Texture -): Sprite => { - const sprite = new Sprite(texture) - sprite.anchor.set(0.5) - sprite.width = renderable.width - sprite.height = renderable.height - return sprite -} - -const createWallNode = (renderable: { - width: number - height: number - color: number - accent: number -}): Container => { - const node = new Container() - - const body = new Graphics() - body.roundRect( - -renderable.width * 0.5, - -renderable.height * 0.5, - renderable.width, - renderable.height, - 18 - ) - body.fill(renderable.color) - body.stroke({ - color: renderable.accent, - width: 2, - alpha: 0.45 - }) - - const inset = new Graphics() - inset.roundRect( - -renderable.width * 0.5 + 10, - -renderable.height * 0.5 + 10, - Math.max(renderable.width - 20, 0), - Math.max(renderable.height - 20, 0), - 12 - ) - inset.stroke({ - color: 0x10181f, - width: 2, - alpha: 0.42 - }) - - node.addChild(body) - node.addChild(inset) - return node -} - -const createPickupNode = (renderable: { - width: number - height: number - color: number - accent: number -}): Container => { - const node = new Container() - - const glow = new Graphics() - glow.circle(0, 0, renderable.width * 0.82) - glow.fill({ - color: renderable.color, - alpha: 0.14 - }) - - const crystal = new Graphics() - crystal.moveTo(0, -renderable.height * 0.5) - crystal.lineTo(renderable.width * 0.42, 0) - crystal.lineTo(0, renderable.height * 0.5) - crystal.lineTo(-renderable.width * 0.42, 0) - crystal.closePath() - crystal.fill(renderable.color) - crystal.stroke({ - color: renderable.accent, - width: 2 - }) - - node.addChild(glow) - node.addChild(crystal) - return node -} - -export const textureForCurrentFrame = ( - frames: PlayerFrameAtlas, - frame: CurrentPlayerFrameValue -): Texture => { - const facing = - frame.row === 1 ? "Down" - : frame.row === 2 ? "Left" - : frame.row === 3 ? "Right" - : "Up" - return frames[facing][frame.column - 1]! -} - -export const destroyRenderNode = (renderNode: RenderNode): void => { - renderNode.node.destroy({ - children: true - }) -} - -/** - * Builds the render node for one renderable; `RenderSync` attaches it. - */ -export const createRenderNode = ( - renderable: { - readonly kind: "player" | "wall" | "pickup" - readonly width: number - readonly height: number - readonly color: number - readonly accent: number - }, - frames: PlayerFrameAtlas, - frame: CurrentPlayerFrameValue -): RenderNode => - renderable.kind === "player" - ? { kind: "player", node: createPlayerNode(renderable, textureForCurrentFrame(frames, frame)) } - : renderable.kind === "wall" - ? { kind: "wall", node: createWallNode(renderable) } - : { kind: "pickup", node: createPickupNode(renderable) } diff --git a/examples/top-down/render/player-sheet.ts b/examples/top-down/render/player-sheet.ts deleted file mode 100644 index bafdd64..0000000 --- a/examples/top-down/render/player-sheet.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Rectangle, Texture } from "pixi.js" - -import { PLAYER_FRAME_SIZE } from "../constants.ts" -import type { PlayerFrameAtlas } from "../types.ts" - -const createFrameRow = (sheet: Texture, row: 1 | 2 | 3 | 4) => - [ - new Texture({ - source: sheet.source, - frame: new Rectangle(PLAYER_FRAME_SIZE, row * PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE) - }), - new Texture({ - source: sheet.source, - frame: new Rectangle(2 * PLAYER_FRAME_SIZE, row * PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE) - }), - new Texture({ - source: sheet.source, - frame: new Rectangle(3 * PLAYER_FRAME_SIZE, row * PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE) - }), - new Texture({ - source: sheet.source, - frame: new Rectangle(4 * PLAYER_FRAME_SIZE, row * PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE) - }), - new Texture({ - source: sheet.source, - frame: new Rectangle(5 * PLAYER_FRAME_SIZE, row * PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE) - }), - new Texture({ - source: sheet.source, - frame: new Rectangle(6 * PLAYER_FRAME_SIZE, row * PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE, PLAYER_FRAME_SIZE) - }) - ] as const - -export const createPlayerFrameAtlas = (sheet: Texture): PlayerFrameAtlas => ({ - Down: createFrameRow(sheet, 1), - Left: createFrameRow(sheet, 2), - Right: createFrameRow(sheet, 3), - Up: createFrameRow(sheet, 4) -}) diff --git a/examples/top-down/runtime.ts b/examples/top-down/runtime.ts deleted file mode 100644 index 15f2ace..0000000 --- a/examples/top-down/runtime.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { Keyboard } from "@typeonce/ritmo-browser" -import * as Result from "@typeonce/ritmo/Result" -import { - Facing, - FrameContext, - Game, - KeyboardInput, - Locomotion, - TopDownHost, - RenderNodes -} from "./schema.ts" -import { pickupLayout } from "./content.ts" -import { inputBindings } from "./types.ts" -import type { FrameContextValue, KeyboardInput as KeyboardInputValue, TopDownHostValue } from "./types.ts" - -export const makeEmptyFocusedCollectable = () => ({ - current: null, - label: null, - distance: null -} as const) - -export const makeInitialAnimationClock = () => ({ - frameIndex: 0, - elapsed: 0 -} as const) - -/** - * Initial resources shared by the browser runtime and the headless - * simulation. - */ -export const initialResources = (frame: FrameContextValue) => ({ - DeltaTime: frame.deltaSeconds, - Viewport: frame.viewport, - Camera: { - x: frame.viewport.width * 0.5, - y: frame.viewport.height * 0.5 - }, - InputState: Keyboard.idle(inputBindings), - FocusedCollectable: makeEmptyFocusedCollectable(), - CollectedCount: 0, - TotalCollectables: pickupLayout.length, - AnimationClock: makeInitialAnimationClock(), - CurrentPlayerFrame: { - row: 1, - column: 1 - } -} as const) - -export const initialMachines = () => - Game.Runtime.machines( - Game.Runtime.machine(Facing, "Down"), - Game.Runtime.machine(Locomotion, "Idle") - ) - -/** - * Reads the frame context live from the Pixi host. - */ -const hostFrameContext = (host: TopDownHostValue): FrameContextValue => ({ - get deltaSeconds() { - return host.clock.deltaSeconds - }, - get viewport() { - return { - width: host.application.screen.width, - height: host.application.screen.height - } - } -}) - -export const describeRuntimeError = (error: { readonly resources: { readonly Viewport?: unknown; readonly Camera?: unknown } }) => - error.resources.Viewport - ? "Invalid top-down viewport." - : error.resources.Camera - ? "Invalid top-down camera." - : "Invalid top-down runtime resources." - -export const createTopDownRuntime = ( - host: TopDownHostValue, - keyboard: KeyboardInputValue -) => { - const frame = hostFrameContext(host) - const made = Game.Runtime.make({ - services: Game.Runtime.services( - Game.Runtime.service(KeyboardInput, keyboard), - Game.Runtime.service(FrameContext, frame), - Game.Runtime.service(TopDownHost, host), - Game.Runtime.service(RenderNodes, host.nodes) - ), - resources: initialResources(frame), - machines: initialMachines() - }) - return Result.match(made, { - onSuccess: Result.success, - onFailure: (error) => Result.failure({ message: describeRuntimeError(error) }) - }) -} diff --git a/examples/top-down/schedules.ts b/examples/top-down/schedules.ts deleted file mode 100644 index 00e1f64..0000000 --- a/examples/top-down/schedules.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - ApplyWorldCameraTransformSystem, - AdvanceAnimationClockSystem, - CaptureFrameContextSystem, - CaptureInputSystem, - CollectFocusedCollectableSystem, - RenderNodesSystem, - MovePlayerSystem, - PlanPlayerVelocitySystem, - ResolveCurrentPlayerFrameSystem, - ResolveFacingSystem, - ResolveLocomotionSystem, - ResetAnimationClockSystem, - SetupWorldSystem, - SyncCameraSystem, - SyncHudSystem, - SyncPickupPresentationSystem, - SyncPlayerSpriteSystem, - UpdateFocusedCollectableSystem -} from "./systems/index.ts" -import { Game } from "./schema.ts" - -const animationSchedule = Game.Schedule( - ResetAnimationClockSystem, - AdvanceAnimationClockSystem, - ResolveCurrentPlayerFrameSystem -) - -const gameplaySchedule = Game.Schedule( - CaptureFrameContextSystem, - CaptureInputSystem, - PlanPlayerVelocitySystem, - MovePlayerSystem, - UpdateFocusedCollectableSystem, - CollectFocusedCollectableSystem, - ResolveFacingSystem, - ResolveLocomotionSystem, - Game.Schedule.applyDeferred(), - Game.Schedule.applyStateTransitions() -) - -/** - * Everything that changes world state. It needs only the keyboard and the - * frame context, so it also runs headless (see `simulation.ts`). - */ -export const simulationSchedule = Game.Schedule( - gameplaySchedule, - animationSchedule, - SyncCameraSystem -) - -/** - * Mirrors the world into Pixi and the HUD. Reads world state only. - */ -export const presentationSchedule = Game.Schedule( - ApplyWorldCameraTransformSystem, - RenderNodesSystem, - SyncPlayerSpriteSystem, - SyncPickupPresentationSystem, - SyncHudSystem -) - -export const setupWorldSchedule = Game.Schedule( - SetupWorldSystem, - Game.Schedule.applyDeferred(), - SyncCameraSystem -) - -export const setupSchedule = Game.Schedule( - setupWorldSchedule, - presentationSchedule -) - -export const updateSchedule = Game.Schedule( - simulationSchedule, - presentationSchedule -) diff --git a/examples/top-down/systems/animation.ts b/examples/top-down/systems/animation.ts deleted file mode 100644 index 065e99d..0000000 --- a/examples/top-down/systems/animation.ts +++ /dev/null @@ -1,159 +0,0 @@ - -import { PLAYER_FRAME_SECONDS, facingRows } from "../constants.ts" -import { advanceFrameIndex, lengthSquared } from "../math.ts" -import { PlayerVelocityQuery } from "../queries.ts" -import { - AnimationClock, - CurrentPlayerFrame, - DeltaTime, - Facing, - Game, - Locomotion -} from "../schema.ts" - -export const ResolveFacingSystem = Game.System( - "TopDown/ResolveFacing", - { - queries: { - player: PlayerVelocityQuery - }, - machines: { - facing: Game.System.machine(Facing) - }, - nextMachines: { - facing: Game.System.nextState(Facing) - } - }, - ({ queries, machines, nextMachines }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const velocity = player.value.data.velocity.get() - const ax = Math.abs(velocity.x) - const ay = Math.abs(velocity.y) - if (ax === 0 && ay === 0) { - return - } - - const current = machines.facing.get() - const nextFacing = - ax > ay ? (velocity.x < 0 ? "Left" : "Right") - : ay > ax ? (velocity.y < 0 ? "Up" : "Down") - : current - - nextMachines.facing.setIfChanged(nextFacing) - } -) - -export const ResolveLocomotionSystem = Game.System( - "TopDown/ResolveLocomotion", - { - queries: { - player: PlayerVelocityQuery - }, - nextMachines: { - locomotion: Game.System.nextState(Locomotion) - } - }, - ({ queries, nextMachines }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const velocity = player.value.data.velocity.get() - nextMachines.locomotion.setIfChanged( - lengthSquared(velocity) > 0 ? "Walking" : "Idle" - ) - } -) - -export const ResetAnimationClockSystem = Game.System( - "TopDown/ResetAnimationClock", - { - when: [ - Game.Condition.or( - Game.Condition.stateChanged(Facing), - Game.Condition.stateChanged(Locomotion) - ) - ], - resources: { - clock: Game.System.writeResource(AnimationClock) - } - }, - ({ resources }) => - { - resources.clock.set({ - frameIndex: 0, - elapsed: 0 - }) - } -) - -export const AdvanceAnimationClockSystem = Game.System( - "TopDown/AdvanceAnimationClock", - { - resources: { - deltaTime: Game.System.readResource(DeltaTime), - clock: Game.System.writeResource(AnimationClock) - }, - machines: { - locomotion: Game.System.machine(Locomotion) - } - }, - ({ resources, machines }) => - { - const locomotion = machines.locomotion.get() - const clock = resources.clock.get() - - if (locomotion === "Idle") { - resources.clock.set({ - frameIndex: 0, - elapsed: 0 - }) - return - } - - let frameIndex = clock.frameIndex - let elapsed = clock.elapsed + resources.deltaTime.get() - - while (elapsed >= PLAYER_FRAME_SECONDS) { - elapsed -= PLAYER_FRAME_SECONDS - frameIndex = advanceFrameIndex(frameIndex) - } - - resources.clock.set({ - frameIndex, - elapsed - }) - } -) - -export const ResolveCurrentPlayerFrameSystem = Game.System( - "TopDown/ResolveCurrentPlayerFrame", - { - resources: { - clock: Game.System.readResource(AnimationClock), - frame: Game.System.writeResource(CurrentPlayerFrame) - }, - machines: { - facing: Game.System.machine(Facing), - locomotion: Game.System.machine(Locomotion) - } - }, - ({ resources, machines }) => - { - const facing = machines.facing.get() - const locomotion = machines.locomotion.get() - const clock = resources.clock.get() - - resources.frame.set({ - row: facingRows[facing], - column: locomotion === "Idle" ? 1 : (clock.frameIndex + 1) as import("../types.ts").CurrentPlayerFrameValue["column"] - }) - } -) diff --git a/examples/top-down/systems/camera.ts b/examples/top-down/systems/camera.ts deleted file mode 100644 index 7004ed0..0000000 --- a/examples/top-down/systems/camera.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { WORLD_HEIGHT, WORLD_WIDTH } from "../constants.ts" -import { clamp } from "../math.ts" -import { PlayerCameraQuery } from "../queries.ts" -import { Camera, Game, Viewport } from "../schema.ts" - -export const SyncCameraSystem = Game.System( - "TopDown/SyncCamera", - { - queries: { - player: PlayerCameraQuery - }, - resources: { - viewport: Game.System.readResource(Viewport), - camera: Game.System.writeResource(Camera) - } - }, - ({ queries, resources }) => - { - const player = queries.player.singleOptional() - if (!player.ok || !player.value) { - return - } - - const playerPosition = player.value.data.position.get() - const viewport = resources.viewport.get() - const halfViewWidth = viewport.width * 0.5 - const halfViewHeight = viewport.height * 0.5 - const minCameraX = halfViewWidth - const maxCameraX = WORLD_WIDTH - halfViewWidth - const minCameraY = halfViewHeight - const maxCameraY = WORLD_HEIGHT - halfViewHeight - - resources.camera.setRaw({ - x: minCameraX > maxCameraX ? WORLD_WIDTH * 0.5 : clamp(playerPosition.x, minCameraX, maxCameraX), - y: minCameraY > maxCameraY ? WORLD_HEIGHT * 0.5 : clamp(playerPosition.y, minCameraY, maxCameraY) - }) - } -) diff --git a/examples/top-down/systems/hud.ts b/examples/top-down/systems/hud.ts deleted file mode 100644 index 004fadd..0000000 --- a/examples/top-down/systems/hud.ts +++ /dev/null @@ -1,56 +0,0 @@ - -import { - CollectedCount, - Facing, - FocusedCollectable, - Game, - Locomotion, - TopDownHost, - TotalCollectables -} from "../schema.ts" - -export const SyncHudSystem = Game.System( - "TopDown/SyncHud", - { - resources: { - focused: Game.System.changedResource(FocusedCollectable), - collectedCount: Game.System.changedResource(CollectedCount), - totalCollectables: Game.System.changedResource(TotalCollectables) - }, - machines: { - facing: Game.System.machine(Facing), - locomotion: Game.System.machine(Locomotion) - }, - transitionEvents: { - facing: Game.System.readTransitionEvent(Facing), - locomotion: Game.System.readTransitionEvent(Locomotion) - }, - services: { - host: Game.System.service(TopDownHost) - } - }, - ({ resources, machines, transitionEvents, services }) => - { - // Each label is rewritten only when what it shows changed since this system's previous run. - const countsChanged = resources.collectedCount.changed() || resources.totalCollectables.changed() - const collectedCount = resources.collectedCount.get() - const totalCollectables = resources.totalCollectables.get() - const remaining = Math.max(totalCollectables - collectedCount, 0) - - if (countsChanged || transitionEvents.facing.all().length > 0 || transitionEvents.locomotion.all().length > 0) { - services.host.hud.stats.textContent = `Collected ${collectedCount}/${totalCollectables} | Facing ${machines.facing.get()} | ${machines.locomotion.get()}` - } - if (resources.focused.changed()) { - const focused = resources.focused.get() - services.host.hud.prompt.textContent = - focused.label === null - ? "Move with WASD or Arrow keys. Reach a relic and press E." - : `Press E to collect ${focused.label}` - } - if (!countsChanged) return - services.host.hud.hint.textContent = - remaining === 0 - ? "Every collectable has been picked up. The ECS world is now empty except for the player and walls." - : "Player facing and locomotion now live in typed state machines. Pixi only renders the selected sprite frame." - } -) diff --git a/examples/top-down/systems/index.ts b/examples/top-down/systems/index.ts deleted file mode 100644 index af30654..0000000 --- a/examples/top-down/systems/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { SetupWorldSystem } from "./setup.ts" -export { CaptureFrameContextSystem, CaptureInputSystem } from "./input.ts" -export { PlanPlayerVelocitySystem, MovePlayerSystem } from "./movement.ts" -export { UpdateFocusedCollectableSystem, CollectFocusedCollectableSystem } from "./interaction.ts" -export { - AdvanceAnimationClockSystem, - ResolveCurrentPlayerFrameSystem, - ResolveFacingSystem, - ResetAnimationClockSystem, - ResolveLocomotionSystem -} from "./animation.ts" -export { SyncCameraSystem } from "./camera.ts" -export { - ApplyWorldCameraTransformSystem, - RenderNodesSystem, - SyncPickupPresentationSystem, - SyncPlayerSpriteSystem -} from "./render-sync.ts" -export { SyncHudSystem } from "./hud.ts" diff --git a/examples/top-down/systems/render-sync.ts b/examples/top-down/systems/render-sync.ts deleted file mode 100644 index 13cccbc..0000000 --- a/examples/top-down/systems/render-sync.ts +++ /dev/null @@ -1,117 +0,0 @@ - -import { RenderSync } from "@typeonce/ritmo-render" - -import { PickupRenderQuery, PlayerRenderQuery } from "../queries.ts" -import { - Camera, - CurrentPlayerFrame, - FocusedCollectable, - Game, - Position, - Renderable, - RenderNodes, - TopDownHost, - Viewport -} from "../schema.ts" -import { createRenderNode, textureForCurrentFrame } from "../render/nodes.ts" - -export const ApplyWorldCameraTransformSystem = Game.System( - "TopDown/ApplyWorldCameraTransform", - { - resources: { - viewport: Game.System.readResource(Viewport), - camera: Game.System.readResource(Camera) - }, - services: { - host: Game.System.service(TopDownHost) - } - }, - ({ resources, services }) => - { - services.host.world.position.set( - resources.viewport.get().width * 0.5 - resources.camera.get().x, - resources.viewport.get().height * 0.5 - resources.camera.get().y - ) - } -) - -const render = RenderSync.system(Game, { - name: "TopDown/Render", - renderable: Renderable, - transform: Position, - registry: RenderNodes, - services: { host: TopDownHost }, - // The player's frame is refreshed every tick by SyncPlayerSpriteSystem. - create: ({ renderable, services }) => - createRenderNode(renderable, services.host.playerFrames, { row: 1, column: 1 }), - apply: (renderNode, { transform }) => { - renderNode.node.position.set(transform.x, transform.y) - } -}) - -export const RenderNodesSystem = render - -export const SyncPlayerSpriteSystem = Game.System( - "TopDown/SyncPlayerSprite", - { - queries: { - players: PlayerRenderQuery - }, - resources: { - playerFrame: Game.System.readResource(CurrentPlayerFrame) - }, - services: { - host: Game.System.service(TopDownHost) - } - }, - ({ queries, resources, services }) => - { - const currentFrame = resources.playerFrame.get() - - for (const match of queries.players.each()) { - const renderNode = services.host.nodes.get(match.entity.id) - if (!renderNode || renderNode.kind !== "player") { - continue - } - - const renderable = match.data.renderable.get() - renderNode.node.texture = textureForCurrentFrame(services.host.playerFrames, currentFrame) - // Sizing sets the scale, so it must come after any scale reset. - renderNode.node.width = renderable.width - renderNode.node.height = renderable.height - renderNode.node.alpha = 1 - renderNode.node.rotation = 0 - } - } -) - -export const SyncPickupPresentationSystem = Game.System( - "TopDown/SyncPickupPresentation", - { - queries: { - pickups: PickupRenderQuery - }, - resources: { - focused: Game.System.readResource(FocusedCollectable) - }, - services: { - host: Game.System.service(TopDownHost) - } - }, - ({ queries, resources, services }) => - { - const focusedId: number | null = resources.focused.get().current ?? null - - for (const match of queries.pickups.each()) { - const renderNode = services.host.nodes.get(match.entity.id) - if (!renderNode || renderNode.kind !== "pickup") { - continue - } - - const isFocused = match.entity.id === focusedId - renderNode.node.rotation += 0.01 - renderNode.node.scale.set(isFocused ? 1.12 : 1) - renderNode.node.alpha = isFocused ? 1 : 0.86 - } - } -) diff --git a/examples/top-down/tsconfig.json b/examples/top-down/tsconfig.json deleted file mode 100644 index eca7f23..0000000 --- a/examples/top-down/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "include": ["./**/*.ts"] -} diff --git a/examples/top-down/types.ts b/examples/top-down/types.ts deleted file mode 100644 index c6c18b0..0000000 --- a/examples/top-down/types.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { Keyboard } from "@typeonce/ritmo-browser" -import type { NodeRegistry } from "@typeonce/ritmo-render" -import type { Application, Container, Sprite, Texture } from "pixi.js" -import type * as Size2Value from "@typeonce/ritmo-math/Size2" -import type * as Vector2Value from "@typeonce/ritmo-math/Vector2" - -export type Vector2 = Vector2Value.Vector2 -export type Size2 = Size2Value.Size2 -export type FacingValue = "Down" | "Left" | "Right" | "Up" -export type LocomotionValue = "Idle" | "Walking" -export type AnimationFrameIndex = 0 | 1 | 2 | 3 | 4 | 5 -export type CurrentPlayerFrameValue = { - row: 1 | 2 | 3 | 4 - column: 1 | 2 | 3 | 4 | 5 | 6 -} -export type AnimationClockValue = { - frameIndex: AnimationFrameIndex - elapsed: number -} -export const inputBindings = { - up: ["ArrowUp", "w"], - down: ["ArrowDown", "s"], - left: ["ArrowLeft", "a"], - right: ["ArrowRight", "d"], - interact: ["e", " "] -} as const -export type InputStateValue = Keyboard.Snapshot -export type KeyboardInput = Keyboard.Actions -export type HudRefs = { - prompt: HTMLElement - stats: HTMLElement - hint: HTMLElement -} -export type PlayerSpriteNode = { - kind: "player" - node: Sprite -} -export type StaticNode = { - kind: "wall" | "pickup" - node: Container -} -export type RenderNode = PlayerSpriteNode | StaticNode -export type PlayerFrameAtlas = Readonly> -/** - * What one fixed update needs from the host: the step length and the - * current viewport size. The browser reads them from Pixi; a headless - * simulation passes constants. - */ -export type FrameContextValue = { - readonly deltaSeconds: number - readonly viewport: { - readonly width: number - readonly height: number - } -} -export type TopDownHostValue = { - application: Application - world: Container - actorLayer: Container - nodes: NodeRegistry.NodeRegistry - playerFrames: PlayerFrameAtlas - hud: HudRefs - clock: { - deltaSeconds: number - } -} diff --git a/examples/algorithms/package.json b/integration/algorithms/package.json similarity index 73% rename from examples/algorithms/package.json rename to integration/algorithms/package.json index 90fdb34..2f53a65 100644 --- a/examples/algorithms/package.json +++ b/integration/algorithms/package.json @@ -1,5 +1,5 @@ { - "name": "@typeonce/ritmo-example-algorithms", + "name": "@typeonce/ritmo-integration-algorithms", "private": true, "type": "module", "dependencies": { diff --git a/examples/algorithms/schema.ts b/integration/algorithms/schema.ts similarity index 100% rename from examples/algorithms/schema.ts rename to integration/algorithms/schema.ts diff --git a/examples/algorithms/simulation.ts b/integration/algorithms/simulation.ts similarity index 100% rename from examples/algorithms/simulation.ts rename to integration/algorithms/simulation.ts diff --git a/examples/algorithms/test/simulation.test.ts b/integration/algorithms/test/simulation.test.ts similarity index 89% rename from examples/algorithms/test/simulation.test.ts rename to integration/algorithms/test/simulation.test.ts index a6ff552..430072b 100644 --- a/examples/algorithms/test/simulation.test.ts +++ b/integration/algorithms/test/simulation.test.ts @@ -13,7 +13,7 @@ const run = (seed: number, steps: number) => { return { simulation, frames } } -describe("algorithms example", () => { +describe("algorithms simulation", () => { it("replays exactly from the same seed, and differs for another", () => { expect(run(1337, 120).frames).toEqual(run(1337, 120).frames) expect(run(1338, 1).frames[0]!.chasers).not.toEqual(run(1337, 1).frames[0]!.chasers) @@ -25,7 +25,11 @@ describe("algorithms example", () => { for (const at of frame.chasers) expect(room.some((wall) => Box2.overlapsCircle(wall, at, 10 - 0.05))).toBe(false) } const last = frames[frames.length - 1]! - for (const at of last.chasers) expect(Math.hypot(at.x - last.player.x, at.y - last.player.y)).toBeLessThan(80) + for (const at of last.chasers) { + const dx = at.x - last.player.x + const dy = at.y - last.player.y + expect(Math.sqrt(dx * dx + dy * dy)).toBeLessThan(80) + } }) it("stops a fast shot at the first thing on its path", () => { diff --git a/integration/top-down/constants.ts b/integration/top-down/constants.ts new file mode 100644 index 0000000..f622e47 --- /dev/null +++ b/integration/top-down/constants.ts @@ -0,0 +1,8 @@ +import { Clock } from "@typeonce/ritmo" + +export const WORLD_WIDTH = 2200 +export const WORLD_HEIGHT = 1600 +export const PLAYER_SPEED = 280 +export const PLAYER_SIZE = 36 +export const PLAYER_INTERACT_RADIUS = 72 +export const FIXED_STEP_SECONDS = Clock.hz(60).dt diff --git a/examples/top-down/content.ts b/integration/top-down/content.ts similarity index 100% rename from examples/top-down/content.ts rename to integration/top-down/content.ts diff --git a/examples/top-down/debug.ts b/integration/top-down/debug.ts similarity index 89% rename from examples/top-down/debug.ts rename to integration/top-down/debug.ts index 370bb9b..b5d67f7 100644 --- a/examples/top-down/debug.ts +++ b/integration/top-down/debug.ts @@ -1,8 +1,8 @@ /** - * A headless debug run of the top-down example, and a template for + * A headless debug run of the top-down simulation, and a template for * debugging scripts. * - * node --import tsx examples/top-down/debug.ts + * node --import tsx integration/top-down/debug.ts * * It scripts keyboard input, runs the simulation with a devtools session, * checks an invariant after every frame, and prints what an agent or a @@ -15,7 +15,7 @@ import { Keyboard } from "@typeonce/ritmo-browser" import { Invariant, Session } from "@typeonce/ritmo-devtools" import { WORLD_HEIGHT, WORLD_WIDTH } from "./constants.ts" -import { PlayerCameraQuery } from "./queries.ts" +import { PlayerPositionQuery } from "./queries.ts" import { Game, Player, Position } from "./schema.ts" import { createTopDownSimulation } from "./simulation.ts" import { inputBindings } from "./types.ts" @@ -37,7 +37,7 @@ const { runtime, setup, update } = simulation.value // An invariant is an inspector that returns a violation message, or // `undefined` while it holds. It declares its reads like a system, so // `Session.make` rejects one that reads what the runtime does not provide. -const PlayerInsideWorld = Invariant.fromInspector(Game.Inspector("player inside world", { queries: { player: PlayerCameraQuery } }, ({ queries }) => { +const PlayerInsideWorld = Invariant.fromInspector(Game.Inspector("player inside world", { queries: { player: PlayerPositionQuery } }, ({ queries }) => { for (const match of queries.player.each()) { const position = match.data.position.get() if (position.x < 0 || position.x > WORLD_WIDTH || position.y < 0 || position.y > WORLD_HEIGHT) { diff --git a/examples/top-down/drafts.ts b/integration/top-down/drafts.ts similarity index 72% rename from examples/top-down/drafts.ts rename to integration/top-down/drafts.ts index ff6ac81..1f313ca 100644 --- a/examples/top-down/drafts.ts +++ b/integration/top-down/drafts.ts @@ -5,7 +5,6 @@ import { Game, Position, Player, - Renderable, Velocity, Wall } from "./schema.ts" @@ -14,13 +13,6 @@ export const makeWallDraft = (x: number, y: number, width: number, height: numbe return Game.Command.spawn( Game.Command.entryRaw(Position, { x, y }), Game.Command.entryRaw(Collider, { width, height }), - Game.Command.entry(Renderable, { - kind: "wall", - width, - height, - color: 0x24303b, - accent: 0x87d6ff - }), Game.Command.entry(Wall, {}) ) } @@ -29,13 +21,6 @@ export const makePickupDraft = (x: number, y: number, label: string) => { return Game.Command.spawn( Game.Command.entryRaw(Position, { x, y }), Game.Command.entryRaw(Collider, { width: 28, height: 28 }), - Game.Command.entry(Renderable, { - kind: "pickup", - width: 28, - height: 28, - color: 0xf7c948, - accent: 0xfff1b8 - }), Game.Command.entry(Collectable, { label, radius: PLAYER_INTERACT_RADIUS @@ -51,12 +36,5 @@ export const playerDraft = Game.Command.spawn( Game.Command.entryRaw(Position, { x: 640, y: 260 }), Game.Command.entryRaw(Velocity, { x: 0, y: 0 }), Game.Command.entryRaw(Collider, { width: PLAYER_SIZE, height: PLAYER_SIZE }), - Game.Command.entry(Renderable, { - kind: "player", - width: PLAYER_SIZE, - height: PLAYER_SIZE, - color: 0x4ecdc4, - accent: 0xd9fffb - }), Game.Command.entry(Player, {}) ) diff --git a/examples/top-down/math.ts b/integration/top-down/math.ts similarity index 93% rename from examples/top-down/math.ts rename to integration/top-down/math.ts index e47605c..00cf728 100644 --- a/examples/top-down/math.ts +++ b/integration/top-down/math.ts @@ -3,7 +3,7 @@ import * as InputAxis from "@typeonce/ritmo-math/InputAxis" import * as Scalar from "@typeonce/ritmo-math/Scalar" import * as Vector2Module from "@typeonce/ritmo-math/Vector2" import { WORLD_HEIGHT, WORLD_WIDTH } from "./constants.ts" -import type { AnimationFrameIndex, InputStateValue, Vector2 } from "./types.ts" +import type { InputStateValue, Vector2 } from "./types.ts" export const clamp = (value: number, min: number, max: number): number => { const nextValue = Scalar.Finite.option(value) @@ -26,9 +26,6 @@ export const normalizeMovement = (input: InputStateValue): Vector2 => right: input.right.held }) -export const advanceFrameIndex = (frameIndex: AnimationFrameIndex): AnimationFrameIndex => - frameIndex === 5 ? 0 : (frameIndex + 1) as AnimationFrameIndex - export const intersects = ( firstPosition: Vector2, firstCollider: { width: number; height: number }, diff --git a/examples/top-down/package.json b/integration/top-down/package.json similarity index 72% rename from examples/top-down/package.json rename to integration/top-down/package.json index b42adcb..fb03a97 100644 --- a/examples/top-down/package.json +++ b/integration/top-down/package.json @@ -1,11 +1,10 @@ { - "name": "@typeonce/ritmo-example-top-down", + "name": "@typeonce/ritmo-integration-top-down", "private": true, "type": "module", "dependencies": { "@typeonce/ritmo": "workspace:*", "@typeonce/ritmo-browser": "workspace:*", - "@typeonce/ritmo-render": "workspace:*", "@typeonce/ritmo-math": "workspace:*", "@typeonce/ritmo-devtools": "workspace:*" } diff --git a/examples/top-down/queries.ts b/integration/top-down/queries.ts similarity index 72% rename from examples/top-down/queries.ts rename to integration/top-down/queries.ts index 595fc13..65c2c16 100644 --- a/examples/top-down/queries.ts +++ b/integration/top-down/queries.ts @@ -4,7 +4,6 @@ import { Game, Player, Position, - Renderable, Velocity, Wall } from "./schema.ts" @@ -25,7 +24,7 @@ export const PlayerVelocityQuery = Game.Query({ } }) -export const PlayerCameraQuery = Game.Query({ +export const PlayerPositionQuery = Game.Query({ selection: { position: Game.Query.read(Position), player: Game.Query.read(Player) @@ -47,17 +46,3 @@ export const CollectableQuery = Game.Query({ collectable: Game.Query.read(Collectable) } }) - -export const PlayerRenderQuery = Game.Query({ - selection: { - renderable: Game.Query.read(Renderable), - player: Game.Query.read(Player) - } -}) - -export const PickupRenderQuery = Game.Query({ - selection: { - collectable: Game.Query.read(Collectable), - pickup: Game.Query.read(Renderable) - } -}) diff --git a/integration/top-down/runtime.ts b/integration/top-down/runtime.ts new file mode 100644 index 0000000..1876a5d --- /dev/null +++ b/integration/top-down/runtime.ts @@ -0,0 +1,26 @@ +import { Keyboard } from "@typeonce/ritmo-browser" + +import { pickupLayout } from "./content.ts" +import { Facing, Game, Locomotion } from "./schema.ts" +import { inputBindings } from "./types.ts" +import type { FrameContextValue } from "./types.ts" + +export const makeEmptyFocusedCollectable = () => ({ + current: null, + label: null, + distance: null +} as const) + +export const initialResources = (frame: FrameContextValue) => ({ + DeltaTime: frame.deltaSeconds, + InputState: Keyboard.idle(inputBindings), + FocusedCollectable: makeEmptyFocusedCollectable(), + CollectedCount: 0, + TotalCollectables: pickupLayout.length +} as const) + +export const initialMachines = () => + Game.Runtime.machines( + Game.Runtime.machine(Facing, "Down"), + Game.Runtime.machine(Locomotion, "Idle") + ) diff --git a/integration/top-down/schedules.ts b/integration/top-down/schedules.ts new file mode 100644 index 0000000..3fac4f1 --- /dev/null +++ b/integration/top-down/schedules.ts @@ -0,0 +1,32 @@ +import { + CaptureFrameContextSystem, + CaptureInputSystem, + CollectFocusedCollectableSystem, + MovePlayerSystem, + PlanPlayerVelocitySystem, + ResolveFacingSystem, + ResolveLocomotionSystem, + SetupWorldSystem, + UpdateFocusedCollectableSystem +} from "./systems/index.ts" +import { Game } from "./schema.ts" + +/** Spawns the player, the walls, and the pickups. */ +export const setupWorldSchedule = Game.Schedule( + SetupWorldSystem, + Game.Schedule.applyDeferred() +) + +/** One fixed update. It needs only the keyboard and the frame context, so it runs headless (see `simulation.ts`). */ +export const simulationSchedule = Game.Schedule( + CaptureFrameContextSystem, + CaptureInputSystem, + PlanPlayerVelocitySystem, + MovePlayerSystem, + UpdateFocusedCollectableSystem, + CollectFocusedCollectableSystem, + ResolveFacingSystem, + ResolveLocomotionSystem, + Game.Schedule.applyDeferred(), + Game.Schedule.applyStateTransitions() +) diff --git a/examples/top-down/schema.ts b/integration/top-down/schema.ts similarity index 67% rename from examples/top-down/schema.ts rename to integration/top-down/schema.ts index f86e0c6..8722159 100644 --- a/examples/top-down/schema.ts +++ b/integration/top-down/schema.ts @@ -3,12 +3,9 @@ import * as Size2 from "@typeonce/ritmo-math/Size2" import * as Vector2 from "@typeonce/ritmo-math/Vector2" import type { - AnimationClockValue, - CurrentPlayerFrameValue, FrameContextValue, InputStateValue, - KeyboardInput as KeyboardInputValue, - TopDownHostValue + KeyboardInput as KeyboardInputValue } from "./types.ts" export const Root = Schema.defineRoot("TopDown") @@ -16,13 +13,6 @@ export const Root = Schema.defineRoot("TopDown") export const Position = Descriptor.ConstructedComponent(Vector2)("TopDown/Position") export const Velocity = Descriptor.ConstructedComponent(Vector2)("TopDown/Velocity") export const Collider = Descriptor.ConstructedComponent(Size2)("TopDown/Collider") -export const Renderable = Descriptor.Component<{ - kind: "player" | "wall" | "pickup" - width: number - height: number - color: number - accent: number -}>()("TopDown/Renderable") export const Player = Descriptor.Component<{}>()("TopDown/Player") export const Wall = Descriptor.Component<{}>()("TopDown/Wall") export const Collectable = Descriptor.Component<{ @@ -37,20 +27,14 @@ export type FocusedCollectableValue = { } export const DeltaTime = Descriptor.Resource()("TopDown/DeltaTime") -export const Viewport = Descriptor.ConstructedResource(Size2)("TopDown/Viewport") -export const Camera = Descriptor.ConstructedResource(Vector2)("TopDown/Camera") // Captured from the keyboard at the start of each update; never saved. export const InputState = Descriptor.TransientResource()("TopDown/InputState") export const FocusedCollectable = Descriptor.Resource()("TopDown/FocusedCollectable") export const CollectedCount = Descriptor.Resource()("TopDown/CollectedCount") export const TotalCollectables = Descriptor.Resource()("TopDown/TotalCollectables") -export const AnimationClock = Descriptor.Resource()("TopDown/AnimationClock") -export const CurrentPlayerFrame = Descriptor.Resource()("TopDown/CurrentPlayerFrame") export const KeyboardInput = Descriptor.Service()("TopDown/KeyboardInput") export const FrameContext = Descriptor.Service()("TopDown/FrameContext") -export const TopDownHost = Descriptor.Service()("TopDown/Host") -export const RenderNodes = Descriptor.Service()("TopDown/RenderNodes") export const Game = Schema.bind( Schema.fragment({ @@ -58,7 +42,6 @@ export const Game = Schema.bind( Position, Velocity, Collider, - Renderable, Player, Wall, Collectable @@ -66,20 +49,16 @@ export const Game = Schema.bind( // What each kind of entity always has: spawning a player without a // collider does not compile, and Game.System.spawns(Player) allows them. requires: [ - Schema.requires(Player, Position, Velocity, Collider, Renderable), - Schema.requires(Wall, Position, Collider, Renderable), - Schema.requires(Collectable, Position, Collider, Renderable) + Schema.requires(Player, Position, Velocity, Collider), + Schema.requires(Wall, Position, Collider), + Schema.requires(Collectable, Position, Collider) ], resources: { DeltaTime, - Viewport, - Camera, InputState, FocusedCollectable, CollectedCount, - TotalCollectables, - AnimationClock, - CurrentPlayerFrame + TotalCollectables } }), Root diff --git a/examples/top-down/simulation.ts b/integration/top-down/simulation.ts similarity index 86% rename from examples/top-down/simulation.ts rename to integration/top-down/simulation.ts index 1af584a..ece317a 100644 --- a/examples/top-down/simulation.ts +++ b/integration/top-down/simulation.ts @@ -18,7 +18,7 @@ import type { HostClock } from "@typeonce/ritmo" import * as Result from "@typeonce/ritmo/Result" import { FIXED_STEP_SECONDS } from "./constants.ts" -import { describeRuntimeError, initialMachines, initialResources } from "./runtime.ts" +import { initialMachines, initialResources } from "./runtime.ts" import { FrameContext, Game, KeyboardInput } from "./schema.ts" import { setupWorldSchedule, simulationSchedule } from "./schedules.ts" import type { FrameContextValue, KeyboardInput as KeyboardInputValue } from "./types.ts" @@ -27,13 +27,12 @@ export interface TopDownSimulationOptions { readonly keyboard: KeyboardInputValue /** Times the debug traces; `HostClock.test()` keeps every timing at 0. Pass the same clock to `Session.make`. */ readonly hostClock: HostClock.HostClock - /** Defaults to one fixed step on a 1280x720 viewport. */ + /** Defaults to one fixed step at 60 Hz. */ readonly frame?: FrameContextValue } export const defaultFrameContext: FrameContextValue = { - deltaSeconds: FIXED_STEP_SECONDS, - viewport: { width: 1280, height: 720 } + deltaSeconds: FIXED_STEP_SECONDS } export const createTopDownSimulation = (options: TopDownSimulationOptions) => { @@ -52,6 +51,6 @@ export const createTopDownSimulation = (options: TopDownSimulationOptions) => { runtime.debug.nameSchedules({ setup: setupWorldSchedule, update: simulationSchedule }) return Result.success({ runtime, setup: setupWorldSchedule, update: simulationSchedule }) }, - onFailure: (error) => Result.failure({ message: describeRuntimeError(error) }) + onFailure: () => Result.failure({ message: "Invalid top-down runtime resources." }) }) } diff --git a/integration/top-down/systems/facing.ts b/integration/top-down/systems/facing.ts new file mode 100644 index 0000000..5263db9 --- /dev/null +++ b/integration/top-down/systems/facing.ts @@ -0,0 +1,65 @@ + +import { lengthSquared } from "../math.ts" +import { PlayerVelocityQuery } from "../queries.ts" +import { Facing, Game, Locomotion } from "../schema.ts" + +export const ResolveFacingSystem = Game.System( + "TopDown/ResolveFacing", + { + queries: { + player: PlayerVelocityQuery + }, + machines: { + facing: Game.System.machine(Facing) + }, + nextMachines: { + facing: Game.System.nextState(Facing) + } + }, + ({ queries, machines, nextMachines }) => + { + const player = queries.player.singleOptional() + if (!player.ok || !player.value) { + return + } + + const velocity = player.value.data.velocity.get() + const ax = Math.abs(velocity.x) + const ay = Math.abs(velocity.y) + if (ax === 0 && ay === 0) { + return + } + + const current = machines.facing.get() + const nextFacing = + ax > ay ? (velocity.x < 0 ? "Left" : "Right") + : ay > ax ? (velocity.y < 0 ? "Up" : "Down") + : current + + nextMachines.facing.setIfChanged(nextFacing) + } +) + +export const ResolveLocomotionSystem = Game.System( + "TopDown/ResolveLocomotion", + { + queries: { + player: PlayerVelocityQuery + }, + nextMachines: { + locomotion: Game.System.nextState(Locomotion) + } + }, + ({ queries, nextMachines }) => + { + const player = queries.player.singleOptional() + if (!player.ok || !player.value) { + return + } + + const velocity = player.value.data.velocity.get() + nextMachines.locomotion.setIfChanged( + lengthSquared(velocity) > 0 ? "Walking" : "Idle" + ) + } +) diff --git a/integration/top-down/systems/index.ts b/integration/top-down/systems/index.ts new file mode 100644 index 0000000..3facef3 --- /dev/null +++ b/integration/top-down/systems/index.ts @@ -0,0 +1,5 @@ +export { SetupWorldSystem } from "./setup.ts" +export { CaptureFrameContextSystem, CaptureInputSystem } from "./input.ts" +export { PlanPlayerVelocitySystem, MovePlayerSystem } from "./movement.ts" +export { UpdateFocusedCollectableSystem, CollectFocusedCollectableSystem } from "./interaction.ts" +export { ResolveFacingSystem, ResolveLocomotionSystem } from "./facing.ts" diff --git a/examples/top-down/systems/input.ts b/integration/top-down/systems/input.ts similarity index 75% rename from examples/top-down/systems/input.ts rename to integration/top-down/systems/input.ts index 651a10f..ab9e7c7 100644 --- a/examples/top-down/systems/input.ts +++ b/integration/top-down/systems/input.ts @@ -4,8 +4,7 @@ import { FrameContext, Game, InputState, - KeyboardInput, - Viewport + KeyboardInput } from "../schema.ts" export const CaptureInputSystem = InputCapture.system(Game, { @@ -18,8 +17,7 @@ export const CaptureFrameContextSystem = Game.System( "TopDown/CaptureFrameContext", { resources: { - deltaTime: Game.System.writeResource(DeltaTime), - viewport: Game.System.writeResource(Viewport) + deltaTime: Game.System.writeResource(DeltaTime) }, services: { frame: Game.System.service(FrameContext) @@ -28,6 +26,5 @@ export const CaptureFrameContextSystem = Game.System( ({ resources, services }) => { resources.deltaTime.set(services.frame.deltaSeconds) - resources.viewport.setRaw(services.frame.viewport) } ) diff --git a/examples/top-down/systems/interaction.ts b/integration/top-down/systems/interaction.ts similarity index 96% rename from examples/top-down/systems/interaction.ts rename to integration/top-down/systems/interaction.ts index e470029..aef691e 100644 --- a/examples/top-down/systems/interaction.ts +++ b/integration/top-down/systems/interaction.ts @@ -1,5 +1,5 @@ -import { CollectableQuery, PlayerCameraQuery } from "../queries.ts" +import { CollectableQuery, PlayerPositionQuery } from "../queries.ts" import { CollectedCount, Collectable, @@ -13,7 +13,7 @@ export const UpdateFocusedCollectableSystem = Game.System( "TopDown/UpdateFocusedCollectable", { queries: { - player: PlayerCameraQuery, + player: PlayerPositionQuery, collectables: CollectableQuery }, resources: { diff --git a/examples/top-down/systems/movement.ts b/integration/top-down/systems/movement.ts similarity index 100% rename from examples/top-down/systems/movement.ts rename to integration/top-down/systems/movement.ts diff --git a/examples/top-down/systems/setup.ts b/integration/top-down/systems/setup.ts similarity index 100% rename from examples/top-down/systems/setup.ts rename to integration/top-down/systems/setup.ts diff --git a/examples/top-down/test/simulation.test.ts b/integration/top-down/test/simulation.test.ts similarity index 96% rename from examples/top-down/test/simulation.test.ts rename to integration/top-down/test/simulation.test.ts index 06cc6bf..d915c91 100644 --- a/examples/top-down/test/simulation.test.ts +++ b/integration/top-down/test/simulation.test.ts @@ -4,7 +4,7 @@ import { Session } from "@typeonce/ritmo-devtools" import { describe, expect, it } from "vitest" import { createTopDownSimulation } from "../simulation.ts" -import { PlayerCameraQuery } from "../queries.ts" +import { PlayerPositionQuery } from "../queries.ts" import { CollectedCount, Game } from "../schema.ts" import { inputBindings } from "../types.ts" @@ -16,7 +16,7 @@ const start = (timeline: Keyboard.Timeline) => { return { runtime, update } } -const PlayerPosition = Game.Inspector("Test/PlayerPosition", { queries: { player: PlayerCameraQuery } }, ({ queries }) => +const PlayerPosition = Game.Inspector("Test/PlayerPosition", { queries: { player: PlayerPositionQuery } }, ({ queries }) => queries.player.each().map((match) => match.data.position.get())) describe("top-down simulation", () => { diff --git a/integration/top-down/types.ts b/integration/top-down/types.ts new file mode 100644 index 0000000..011f63b --- /dev/null +++ b/integration/top-down/types.ts @@ -0,0 +1,17 @@ +import type { Keyboard } from "@typeonce/ritmo-browser" +import type * as Vector2Value from "@typeonce/ritmo-math/Vector2" + +export type Vector2 = Vector2Value.Vector2 +export const inputBindings = { + up: ["ArrowUp", "w"], + down: ["ArrowDown", "s"], + left: ["ArrowLeft", "a"], + right: ["ArrowRight", "d"], + interact: ["e", " "] +} as const +export type InputStateValue = Keyboard.Snapshot +export type KeyboardInput = Keyboard.Actions +/** What one fixed update needs from the host: the step length. */ +export type FrameContextValue = { + readonly deltaSeconds: number +} diff --git a/package.json b/package.json index 5d70fd4..ff867ca 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,11 @@ "bench:update": "node --expose-gc --import tsx scripts/bench.ts --update", "bench:types:generate": "node --import tsx packages/core/bench/types/generate.ts", "coverage": "vitest --coverage", - "example:genetics": "vite --open /examples/genetics/", - "example:pixi": "vite --open /examples/pixi/", - "example:platformer": "vite --open /examples/platformer/", - "example:top-down": "vite --open /examples/top-down/", "build": "rm -rf packages/*/dist packages/*/tsconfig.tsbuildinfo && tsc -b tsconfig.packages.json --pretty false", "pack:check": "node --import tsx scripts/pack-check.ts", "changeset": "changeset", "version-packages": "changeset version && node --import tsx scripts/sync-version.ts && pnpm install --lockfile-only", "release": "pnpm build && changeset publish", - "example:algorithms": "node --import tsx examples/algorithms/main.ts", "bench:math": "node --import tsx packages/math/bench/helpers.ts", "lint": "oxlint" }, @@ -30,11 +25,9 @@ "@changesets/cli": "2.31.1", "@typeonce/oxlint-plugin-ritmo": "workspace:*", "@typeonce/ritmo-math": "workspace:*", - "@types/matter-js": "^0.20.2", "@types/node": "^25.5.0", "@vitest/coverage-v8": "4.0.18", "@vitest/expect": "4.0.18", - "matter-js": "^0.20.0", "oxlint": "1.88.0", "pixi.js": "^8.17.1", "tstyche": "7.2.3", diff --git a/packages/core/skills/ritmo/SKILL.md b/packages/core/skills/ritmo/SKILL.md index c6cc7b5..55d443f 100644 --- a/packages/core/skills/ritmo/SKILL.md +++ b/packages/core/skills/ritmo/SKILL.md @@ -1,11 +1,11 @@ --- name: ritmo -description: "Apply on every change to a game built on @typeonce/ritmo: gameplay, content, readouts, UI, saves, host integration, splitting modules, or upgrading Ritmo. Where the Ritmo docs are, and principles for where code and state belong so changes stay local and mistakes fail early." +description: "Apply on every change to a game built on @typeonce/ritmo: a mechanic, content, UI or rendering, a bug fix, a refactor, saves, or a Ritmo upgrade. Routes the change to its playbook, says where the Ritmo docs are, and holds the principles for where code and state belong." --- # Ritmo games -Follow the game's existing structure and conventions. These principles decide where something belongs; the game decides its folders, UI framework, renderer, timing, and design. +Follow the game's existing structure and conventions. The playbooks give the steps for each kind of change and the check that proves it; the game decides its folders, UI framework, renderer, timing, and design. ## Where the docs are @@ -14,34 +14,32 @@ Follow the game's existing structure and conventions. These principles decide wh - When upgrading, read each package's `CHANGELOG.md` from the installed version up. Breaking entries name the fix. - Compiler errors and lint messages name the fix. Apply it; never cast around it. +## Pick the playbook + +Read the playbook for the change before writing code. A change that spans several kinds reads each. + +| Change | Playbook | +|---|---| +| A rule, mechanic, or behaviour | [`playbooks/mechanic.md`](playbooks/mechanic.md) | +| Items, kinds, levels, or tuning that reuse behaviour | [`playbooks/content.md`](playbooks/content.md) | +| A readout, panel, menu, or anything drawn | [`playbooks/ui-and-render.md`](playbooks/ui-and-render.md) | +| Something behaves wrong | [`playbooks/bug-fix.md`](playbooks/bug-fix.md) | +| Moving or splitting code without changing behaviour | [`playbooks/refactor.md`](playbooks/refactor.md) | +| New state, saves, or a Ritmo upgrade | [`playbooks/state-and-upgrades.md`](playbooks/state-and-upgrades.md) | + ## Principles -**The simulation is the game; everything else is a host.** -- Put rules that change the world in systems. Put input capture, drawing, UI, storage, network, and audio in host code. -- Treat any code that changes saved world state as gameplay, wherever it lives. +**The simulation is the game; everything else is a host.** Rules that change the world are systems. Input capture, drawing, UI, storage, network, and audio are host code. Keep the simulation in one folder, so one glob in the simulation override covers it. Any code that changes saved world state is gameplay, wherever it lives. -**Input goes in as intents; output comes out as reads.** -- Turn player actions into typed host events (`runtime.send(...)`) or input resources that one system validates and applies. UI and render code never change saved world state. +**Intents in, plain data out.** Player actions are typed intents (host events with `runtime.send(...)`, or an input resource) that one system validates and applies. Bots, tests, and replays send the same intents as the UI. UIs and renderers read the world as plain view data and never change saved world state. -**Each fact has one home.** -- Define each closed set (items, tools, building kinds) once. Derive labels, shortcuts, menus, and lookups from it. -- Type tables that must cover the whole set with `satisfies Record`, so a new id fails to compile until every table has an entry. Keep optional overrides `Partial` with an explicit default. +**Each fact has one home.** Define each closed set (items, tools, building kinds) once and derive labels, shortcuts, menus, and lookups from it. Tables that must cover the set are `satisfies Record`, so a new id fails to compile until every table has an entry. -**Each piece of state has an owner and a lifetime.** -- For every new mutable value, choose its home: saved world state (a component or resource), unsaved runtime state (a transient component or resource, or the system's `local`), or host state (a service or host code). Decide what happens to it on new game, restore, and dispose. -- Keep gameplay state (counters, ids, timers, flags) out of module variables: they survive restore, ignore rollback, and are shared by every runtime in the process. A cache inside a pure helper, keyed by its whole input, is fine. -- When moving code, keep each shared cache, pool, and long-lived host object single. +**Each piece of state has an owner and a lifetime.** Every mutable value has one home (saved world state, unsaved runtime state, or host state) and a defined fate on new game, restore, and dispose. Module variables are not a home for gameplay state: they survive restore, ignore rollback, and are shared by every runtime in the process. -**Extend what exists before adding.** -- Search for an existing system, component, definition, or UI piece first. Extend it or add a variant instead of writing a near copy. -- Add a system only for behaviour no existing one covers. New content that reuses behaviour is data. -- Give UI pieces a narrow view model and intent callbacks, not the runtime or a whole host object. +**Extend what exists before adding.** Search for an existing system, component, definition, or UI piece first, and extend it or add a variant instead of a near copy. New content that reuses behaviour is data, not a system. -**Make the next mistake fail loudly.** -- When a rule can be a type, a lint, or a headless test, encode it there instead of in a comment. -- Verify a gameplay change with a headless run that asserts the observable outcome. Reproduce a bug headless before fixing it, then keep the reproduction as a test. -- Keep the type check, `oxlint`, `ritmo-devtools lint `, and the tests in the game's check. If any is missing, add it as `node_modules/@typeonce/ritmo/SETUP.md` describes. -- Test the simulation with `Determinism.check` from `@typeonce/ritmo-devtools`: it runs it twice and names the first value that differs, which catches hidden inputs and module state. +**Make the next mistake fail loudly.** When a rule can be a type, a lint, or a headless test, encode it there instead of in a comment. The game's check runs the type check, `oxlint`, `ritmo-devtools lint `, and the tests; if any is missing, add it as `node_modules/@typeonce/ritmo/SETUP.md` describes. ## Self-check diff --git a/packages/core/skills/ritmo/playbooks/bug-fix.md b/packages/core/skills/ritmo/playbooks/bug-fix.md new file mode 100644 index 0000000..80ea519 --- /dev/null +++ b/packages/core/skills/ritmo/playbooks/bug-fix.md @@ -0,0 +1,11 @@ +# Bug fix + +1. Reproduce headless before changing code: build the simulation on a runtime made with `debug: { hostClock: HostClock.test() }`, script the input, and drive it with a `Session` from `@typeonce/ritmo-devtools` (its README has the loop). +2. Read the lints at the top of `describe()` first; they often name the cause. +3. Encode the bug as an `Invariant` and `run` until it breaks. Explain the wrong value with `why(entity, Component)` and `journal(...)`. +4. When a script cannot reproduce it, record a play session with `Replay.recorder` in the dev build and open the file headless with `Replay.session`. +5. Fix the system that writes the wrong value. Never patch the symptom in UI or render code. +6. Turn the reproduction into a test next to the game. +7. When the same class of bug could come back, encode it: a type, a lint rule in the game's config, or an invariant in the tests. + +**Done when** the test fails without the fix, passes with it, and the game's check passes. diff --git a/packages/core/skills/ritmo/playbooks/content.md b/packages/core/skills/ritmo/playbooks/content.md new file mode 100644 index 0000000..534e85d --- /dev/null +++ b/packages/core/skills/ritmo/playbooks/content.md @@ -0,0 +1,9 @@ +# Content + +1. Find the closed set the content belongs to (items, kinds, levels) and add the id there. Let the type check list every table that needs an entry, and fill each one. +2. Content that reuses existing behaviour is data: add no system. Content that needs new behaviour is also a mechanic ([`mechanic.md`](mechanic.md)). +3. Derive labels, menus, shortcuts, and lookups from the set instead of adding entries by hand. +4. Keep per-id overrides `Partial>` with one explicit default. +5. Decode content loaded at runtime (files, network) at the boundary, with a constructed descriptor or `Decode`, so bad data fails on load instead of in play. + +**Done when** the type check passes with no casts and a headless test spawns the new content and asserts it behaves as its data says. diff --git a/packages/core/skills/ritmo/playbooks/mechanic.md b/packages/core/skills/ritmo/playbooks/mechanic.md new file mode 100644 index 0000000..ae95eb9 --- /dev/null +++ b/packages/core/skills/ritmo/playbooks/mechanic.md @@ -0,0 +1,10 @@ +# A rule or mechanic + +1. Find the systems, components, and resources the behaviour touches. Extend a system when the behaviour is a variant of what it already does; add one only for behaviour no system covers. +2. Name the data before the logic: what the system reads and writes, and where each value lives (see [`state-and-upgrades.md`](state-and-upgrades.md)). Declare exactly that access in the system spec. +3. When the player triggers it, add or extend a typed intent (a host event sent with `runtime.send(...)`, or the input resource) and validate it in the system, never in UI code. +4. Place the system in its schedule. The `Schedule` module header explains markers and `thisTick` reads. +5. Write a headless test: script the input (`Keyboard.scripted`, or the same intents the UI sends), run the schedule, and assert the outcome a player would see. +6. When the mechanic uses time or randomness, add a `Determinism.check` over a scripted run. + +**Done when** the headless test passes and the game's check passes. diff --git a/packages/core/skills/ritmo/playbooks/refactor.md b/packages/core/skills/ritmo/playbooks/refactor.md new file mode 100644 index 0000000..3b52f75 --- /dev/null +++ b/packages/core/skills/ritmo/playbooks/refactor.md @@ -0,0 +1,10 @@ +# Refactor + +1. Before moving code, pin the behaviour: the headless tests pass, and a `Determinism.check` or a recorded scripted run gives a baseline to compare against. +2. Keep the simulation in one folder covered by one glob in the simulation override. `ritmo-devtools lint` reports any module the simulation loads from outside it. +3. Keep each shared cache, pool, registry, and long-lived host object single, with one owner. +4. Move state held in module variables to its home ([`state-and-upgrades.md`](state-and-upgrades.md)). +5. Split large files along what changes together: one closed set per module, a file per system once systems grow, a module per screen. +6. Encode the new boundaries: a `no-restricted-imports` rule per folder whose message names where the code belongs. + +**Done when** the tests pass unchanged, the scripted run matches the baseline, and the game's check passes. diff --git a/packages/core/skills/ritmo/playbooks/state-and-upgrades.md b/packages/core/skills/ritmo/playbooks/state-and-upgrades.md new file mode 100644 index 0000000..a9d0686 --- /dev/null +++ b/packages/core/skills/ritmo/playbooks/state-and-upgrades.md @@ -0,0 +1,12 @@ +# State, saves, and upgrades + +1. Give every new mutable value one home: + - saved world state: a component or resource. It is saved by `runtime.snapshot()`, restored by `runtime.restore(...)`, and rolled back when a system fails. + - unsaved runtime state: a transient component or resource, or the system's `local`. It is rebuilt after restore. + - host state: a service or host code. +2. Decide what the value does on new game, restore, and dispose, and test it: take a snapshot, restore it, and assert the value. +3. Keep saved values plain data, decoded by their descriptor (the `Snapshot` and `Decode` module headers say how). +4. When the saved shape changes, add a step to `Snapshot.migrations(...)` so older saves still load. +5. To upgrade Ritmo, read each package's `CHANGELOG.md` from the installed version up, apply each breaking entry's fix, and fix the remaining compile errors as their messages say. + +**Done when** a snapshot taken before the change restores after it and the game's check passes. diff --git a/packages/core/skills/ritmo/playbooks/ui-and-render.md b/packages/core/skills/ritmo/playbooks/ui-and-render.md new file mode 100644 index 0000000..c98edd5 --- /dev/null +++ b/packages/core/skills/ritmo/playbooks/ui-and-render.md @@ -0,0 +1,10 @@ +# UI, readouts, and rendering + +1. Find the projection: the one place the world is read into plain view data for UIs and renderers (an inspector run with `runtime.inspect(...)`, or a system writing a transient resource). Add the new fields there. If the game has none, add one instead of reading queries from UI code. +2. Give each UI piece a narrow view model and intent callbacks, not the runtime or a whole host object. +3. Send player actions back as intents ([`mechanic.md`](mechanic.md), step 3). UI and render code never change saved world state. +4. Mirror entities into a renderer with `RenderSync` and a `NodeRegistry` service declared in render code. The schema and simulation name no renderer type, so another renderer can replace this one. +5. Keep render-only state (animation timers, effects, camera easing) in render code, a transient resource, or a system's `local`, and decide what it does when a save loads. +6. Lint the import direction: the simulation imports no UI or render code, and each host folder gets a `no-restricted-imports` rule whose message says where the code belongs. + +**Done when** the simulation's headless tests pass unchanged and the game's check passes. A second UI or renderer would need no change to the simulation. diff --git a/packages/core/test/Concepts.test.ts b/packages/core/test/Concepts.test.ts index e6209cd..540dce1 100644 --- a/packages/core/test/Concepts.test.ts +++ b/packages/core/test/Concepts.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest" -import { runConcepts, runLookups } from "../../../examples/concepts.ts" +import { runConcepts, runLookups } from "./concepts.ts" describe("concepts program", () => { it("shares query access and reads entities and machine state explicitly", () => { diff --git a/examples/concepts.ts b/packages/core/test/concepts.ts similarity index 100% rename from examples/concepts.ts rename to packages/core/test/concepts.ts diff --git a/packages/math/src/InputAxis.ts b/packages/math/src/InputAxis.ts index cb76d36..bfbf116 100644 --- a/packages/math/src/InputAxis.ts +++ b/packages/math/src/InputAxis.ts @@ -4,7 +4,7 @@ * * This module is runtime-agnostic: it does not know about keyboard events, * gamepads, or host APIs. It only translates already-sampled booleans into the - * compact movement values used by the examples. + * compact movement values that systems consume. * * Reach for it in the gap between host input sampling and ECS simulation: * capture raw button state in the host, normalize it here, then write the diff --git a/packages/oxlint-plugin/README.md b/packages/oxlint-plugin/README.md index 1e5572c..9546d68 100644 --- a/packages/oxlint-plugin/README.md +++ b/packages/oxlint-plugin/README.md @@ -122,7 +122,7 @@ The simulation runs in tests, bots, replays, and on servers, where there is no p |---|---| | Page globals: `window`, `document`, `navigator`, `matchMedia`, ... | Capture input in the host with `Keyboard`, `Pointer`, or `Touch` from `@typeonce/ritmo-browser` and `InputCapture` into a resource that systems read. Pass page state (size, settings) in with `runtime.send(...)` or a resource. | | Host I/O: `localStorage`, `fetch`, `WebSocket`, `location`, ... | Do the I/O in host code and pass the result in with `runtime.send(...)` or a resource. Save and load the world in the host with `runtime.snapshot()` and `runtime.restore(...)`. | -| Renderer and UI imports: `pixi.js`, `three`, `@typeonce/ritmo-render`, `react`, `preact`, ... | Draw from a render schedule in host files: mirror entities into nodes with `RenderSync` and a `NodeRegistry` from `@typeonce/ritmo-render`. Name a renderer type in the schema with `import type`, which is not reported. | +| Renderer and UI imports, types included: `pixi.js`, `three`, `@typeonce/ritmo-render`, `react`, `preact`, ... | Draw from a render schedule in render code: mirror entities into nodes with `RenderSync` and a `NodeRegistry` from `@typeonce/ritmo-render`, and declare renderer services (a node registry) and their types there; renderer nodes stay in the registry, never in components. The schema and simulation systems name no renderer type, so another renderer can replace this one. | Locals and fields with these names (`term.window`, a `location` parameter) are not globals and are not reported. diff --git a/packages/oxlint-plugin/src/rules/keep-simulation-headless.ts b/packages/oxlint-plugin/src/rules/keep-simulation-headless.ts index f524f61..09839b2 100644 --- a/packages/oxlint-plugin/src/rules/keep-simulation-headless.ts +++ b/packages/oxlint-plugin/src/rules/keep-simulation-headless.ts @@ -21,7 +21,7 @@ const renderers = new Set([ ]) /** Renderer and UI packages, by scope or subpath. */ -const rendererPrefixes = ["@pixi/", "@preact/", "@react-three/", "three/", "react-dom/", "preact/", "@babylonjs/", "solid-js/"] +const rendererPrefixes = ["@typeonce/ritmo-render/", "@typeonce/ritmo-pixi/", "@pixi/", "@preact/", "@react-three/", "three/", "react-dom/", "preact/", "@babylonjs/", "solid-js/"] interface Options { readonly packages?: ReadonlyArray @@ -29,8 +29,9 @@ interface Options { /** * Simulation code runs headless (tests, bots, replays, servers): it never - * touches the page, does host I/O, or imports a renderer. The host feeds it - * input and data, and a render schedule draws it. + * touches the page, does host I/O, or imports a renderer, not even a type, + * so another renderer can replace the current one. The host feeds it input + * and data, and render code draws it. */ export default defineRule({ meta: { @@ -45,7 +46,9 @@ export default defineRule({ io: "`{{name}}` is host I/O, which tests, bots, and replays do not have and cannot reproduce. Do the I/O in host code and pass the result in with `runtime.send(...)` or a resource; save and load the world in host code with `runtime.snapshot()` and `runtime.restore(...)`.", renderer: - "The simulation imports `{{source}}`, a renderer or UI package, so it no longer runs headless. Draw from a render schedule in host files: mirror entities into nodes with `RenderSync` and a `NodeRegistry` from `@typeonce/ritmo-render`. To name a renderer type in the schema (a service), use `import type`, which does not run." + "The simulation imports `{{source}}`, a renderer or UI package, so it no longer runs headless. Draw from a render schedule in render code: mirror entities into nodes with `RenderSync` and a `NodeRegistry` from `@typeonce/ritmo-render`, and declare its services (a node registry) there. Renderer nodes stay in the registry, never in components.", + rendererType: + "The simulation names a type from `{{source}}`, a renderer or UI package, so it is tied to that renderer and another one cannot replace it. Declare renderer services (a node registry) and their types in render code; services are not registered in the schema. Renderer nodes stay in the registry, never in components." }, schema: [ { @@ -68,18 +71,18 @@ export default defineRule({ renderers.has(source) || rendererPrefixes.some((prefix) => source.startsWith(prefix)) || extra.some((entry) => (entry.endsWith("/") ? source.startsWith(entry) : source === entry || source.startsWith(`${entry}/`))) - const checkSource = (node: ESTree.Node, source: string | undefined): void => { - if (source !== undefined && isRenderer(source)) context.report({ node, messageId: "renderer", data: { source } }) + const checkSource = (node: ESTree.Node, source: string | undefined, onlyTypes = false): void => { + if (source !== undefined && isRenderer(source)) context.report({ node, messageId: onlyTypes ? "rendererType" : "renderer", data: { source } }) } return { ImportDeclaration(node) { - if (!importsOnlyTypes(node)) checkSource(node, node.source.value) + checkSource(node, node.source.value, importsOnlyTypes(node)) }, ExportNamedDeclaration(node) { - if (node.source !== null && node.exportKind !== "type") checkSource(node, node.source.value) + if (node.source !== null) checkSource(node, node.source.value, node.exportKind === "type") }, ExportAllDeclaration(node) { - if (node.exportKind !== "type") checkSource(node, node.source.value) + checkSource(node, node.source.value, node.exportKind === "type") }, ImportExpression(node) { checkSource(node, stringValue(node.source)) diff --git a/packages/oxlint-plugin/test/keep-simulation-headless.test.ts b/packages/oxlint-plugin/test/keep-simulation-headless.test.ts index ecdbcc3..bcd46d9 100644 --- a/packages/oxlint-plugin/test/keep-simulation-headless.test.ts +++ b/packages/oxlint-plugin/test/keep-simulation-headless.test.ts @@ -5,9 +5,6 @@ const rule = plugin.rules["keep-simulation-headless"]! tester.run("keep-simulation-headless", rule, { valid: [ - // Naming a renderer type in the schema runs nothing. - "import type { Container } from \"pixi.js\"", - "import { type Container } from \"pixi.js\"", "type Window = typeof window", // Input arrives through ritmo-browser and resources. "import { Keyboard } from \"@typeonce/ritmo-browser\"; const pressed = resources.input.get().jump.pressed", @@ -27,9 +24,18 @@ tester.run("keep-simulation-headless", rule, { { code: "import { Container } from \"pixi.js\"", errors: [{ messageId: "renderer", data: { source: "pixi.js" } }] }, { code: "import * as THREE from \"three\"", errors: [{ messageId: "renderer", data: { source: "three" } }] }, { code: "import { RenderSync } from \"@typeonce/ritmo-render\"", errors: [{ messageId: "renderer", data: { source: "@typeonce/ritmo-render" } }] }, + { code: "import * as NodeRegistry from \"@typeonce/ritmo-render/NodeRegistry\"", errors: [{ messageId: "renderer", data: { source: "@typeonce/ritmo-render/NodeRegistry" } }] }, { code: "import { useState } from \"preact/hooks\"", errors: [{ messageId: "renderer", data: { source: "preact/hooks" } }] }, { code: "export { Sprite } from \"@pixi/sprite\"", errors: [{ messageId: "renderer", data: { source: "@pixi/sprite" } }] }, { code: "const ui = await import(\"react-dom/client\")", errors: [{ messageId: "renderer", data: { source: "react-dom/client" } }] }, + { + // inkline-town moved these out of its schema so the renderer can be swapped. + code: "import type { Container } from \"pixi.js\"", + errors: [{ messageId: "rendererType", data: { source: "pixi.js" } }] + }, + { code: "import { type Container } from \"pixi.js\"", errors: [{ messageId: "rendererType", data: { source: "pixi.js" } }] }, + { code: "import type { NodeRegistry } from \"@typeonce/ritmo-render\"", errors: [{ messageId: "rendererType", data: { source: "@typeonce/ritmo-render" } }] }, + { code: "export type { Sprite } from \"pixi.js\"", errors: [{ messageId: "rendererType", data: { source: "pixi.js" } }] }, { // A game's own renderer package, from the options. code: "import { draw } from \"@town/canvas/draw\"", diff --git a/packages/pixi/src/PixiNodeRegistry.ts b/packages/pixi/src/PixiNodeRegistry.ts index c6887dc..af85e8b 100644 --- a/packages/pixi/src/PixiNodeRegistry.ts +++ b/packages/pixi/src/PixiNodeRegistry.ts @@ -16,6 +16,7 @@ * sprites.clear() * ``` */ +// oxlint-disable-next-line ritmo/keep-simulation-headless -- this package is the Pixi adapter for the render registry, render code by definition import * as NodeRegistry from "@typeonce/ritmo-render/NodeRegistry" /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3b0f562..eab5e62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,6 @@ importers: '@typeonce/ritmo-math': specifier: workspace:* version: link:packages/math - '@types/matter-js': - specifier: ^0.20.2 - version: 0.20.2 '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -29,9 +26,6 @@ importers: '@vitest/expect': specifier: 4.0.18 version: 4.0.18 - matter-js: - specifier: ^0.20.0 - version: 0.20.0 oxlint: specifier: 1.88.0 version: 1.88.0 @@ -60,7 +54,7 @@ importers: specifier: 4.0.18 version: 4.0.18(@types/node@25.5.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.9.1) - examples/algorithms: + integration/algorithms: dependencies: '@typeonce/ritmo': specifier: workspace:* @@ -69,58 +63,7 @@ importers: specifier: workspace:* version: link:../../packages/math - examples/genetics: - dependencies: - '@typeonce/ritmo': - specifier: workspace:* - version: link:../../packages/core - '@typeonce/ritmo-browser': - specifier: workspace:* - version: link:../../packages/browser - '@typeonce/ritmo-pixi': - specifier: workspace:* - version: link:../../packages/pixi - '@typeonce/ritmo-render': - specifier: workspace:* - version: link:../../packages/render - - examples/pixi: - dependencies: - '@typeonce/ritmo': - specifier: workspace:* - version: link:../../packages/core - '@typeonce/ritmo-browser': - specifier: workspace:* - version: link:../../packages/browser - '@typeonce/ritmo-math': - specifier: workspace:* - version: link:../../packages/math - '@typeonce/ritmo-pixi': - specifier: workspace:* - version: link:../../packages/pixi - '@typeonce/ritmo-render': - specifier: workspace:* - version: link:../../packages/render - - examples/platformer: - dependencies: - '@typeonce/ritmo': - specifier: workspace:* - version: link:../../packages/core - '@typeonce/ritmo-browser': - specifier: workspace:* - version: link:../../packages/browser - '@typeonce/ritmo-math': - specifier: workspace:* - version: link:../../packages/math - '@typeonce/ritmo-pixi': - specifier: workspace:* - version: link:../../packages/pixi - '@typeonce/ritmo-render': - specifier: workspace:* - version: link:../../packages/render - - examples/top-down: + integration/top-down: dependencies: '@typeonce/ritmo': specifier: workspace:* @@ -134,9 +77,6 @@ importers: '@typeonce/ritmo-math': specifier: workspace:* version: link:../../packages/math - '@typeonce/ritmo-render': - specifier: workspace:* - version: link:../../packages/render packages/browser: dependencies: @@ -836,9 +776,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/matter-js@0.20.2': - resolution: {integrity: sha512-3PPKy3QxvZ89h9+wdBV2488I1JLVs7DEpIkPvgO8JC1mUdiVSO37ZIvVctOTD7hIq8OAL2gJ3ugGSuUip6DhCw==} - '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -1331,9 +1268,6 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} - matter-js@0.20.0: - resolution: {integrity: sha512-iC9fYR7zVT3HppNnsFsp9XOoQdQN2tUyfaKg4CHLH8bN+j6GT4Gw7IH2rP0tflAebrHFw730RR3DkVSZRX8hwA==} - merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -2254,8 +2188,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/matter-js@0.20.2': {} - '@types/node@12.20.55': {} '@types/node@25.5.0': @@ -2677,8 +2609,6 @@ snapshots: dependencies: semver: 7.7.4 - matter-js@0.20.0: {} - merge2@1.4.1: {} micromatch@4.0.8: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e535fd3..0774d1b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,3 @@ packages: - packages/* - - examples/* + - integration/* diff --git a/scripts/pack-check.ts b/scripts/pack-check.ts index d44f80a..e790192 100644 --- a/scripts/pack-check.ts +++ b/scripts/pack-check.ts @@ -53,7 +53,7 @@ for (const directory of packages) { throw new Error(`${manifest.name} tarball contains test or build-info files`) } if (directory === "core") { - for (const required of ["package/skills/ritmo/SKILL.md", "package/SETUP.md"]) { + for (const required of ["package/skills/ritmo/SKILL.md", "package/skills/ritmo/playbooks/mechanic.md", "package/SETUP.md"]) { if (!listing.includes(required)) throw new Error(`${manifest.name} tarball is missing ${required}`) } } diff --git a/tsconfig.base.json b/tsconfig.base.json index 4a5e179..51abd9a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -8,7 +8,7 @@ "composite": true, "target": "ES2022", // No DOM: package code must not reach platform APIs. Host packages and the - // workspace configs that check examples add "DOM" themselves. + // root workspace config (tests, integration, scripts) add "DOM" themselves. "lib": ["ES2022"], "module": "NodeNext", "moduleResolution": "NodeNext", diff --git a/tsconfig.json b/tsconfig.json index 58f8b4b..e870d28 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,13 +4,11 @@ "references": [{ "path": "./tsconfig.packages.json" }], "include": [ "scripts/**/*.ts", - "examples/**/*.ts", + "integration/**/*.ts", "packages/*/test/**/*.ts", "packages/*/dtslint/**/*.ts", "packages/*/bench/**/*.ts", - "**/vite.config.ts", - "**/vitest.config.ts", - "vitest.shared.ts" + "**/vitest.config.ts" ], "compilerOptions": { "rootDir": ".", diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 33f9068..0000000 --- a/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import tsconfigPaths from "vite-tsconfig-paths" -import { defineConfig } from "vite" - -export default defineConfig({ - plugins: [tsconfigPaths({ ignoreConfigErrors: true })], - build: { - rollupOptions: { - input: { - genetics: "examples/genetics/index.html", - pixi: "examples/pixi/index.html", - platformer: "examples/platformer/index.html", - "top-down": "examples/top-down/index.html" - } - } - }, - server: { - open: "/examples/pixi/" - } -}) diff --git a/vitest.config.ts b/vitest.config.ts index f34decd..48426d8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,15 @@ +import tsconfigPaths from "vite-tsconfig-paths" import { defineConfig } from "vitest/config" -import shared from "./vitest.shared.ts" - -export default defineConfig(shared) +export default defineConfig({ + plugins: [tsconfigPaths({ ignoreConfigErrors: true })], + test: { + include: ["packages/*/test/**/*.test.ts", "integration/*/test/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/dist/**", "**/coverage/**", "**/dtslint/**"], + coverage: { + provider: "v8", + reportsDirectory: "coverage", + exclude: ["node_modules/", "dist/", "coverage/", "dtslint/", "**/*.d.ts", "**/*.config.*"] + } + } +}) diff --git a/vitest.shared.ts b/vitest.shared.ts deleted file mode 100644 index 21309b3..0000000 --- a/vitest.shared.ts +++ /dev/null @@ -1,30 +0,0 @@ -import tsconfigPaths from "vite-tsconfig-paths" -import type { ViteUserConfig } from "vitest/config" - -const config: ViteUserConfig = { - plugins: [tsconfigPaths({ ignoreConfigErrors: true })], - test: { - include: ["packages/*/test/**/*.test.ts", "examples/*/test/**/*.test.ts"], - exclude: [ - "**/node_modules/**", - "**/dist/**", - "**/coverage/**", - "**/dtslint/**" - ], - coverage: { - provider: "v8", - reportsDirectory: "coverage", - exclude: [ - "node_modules/", - "dist/", - "coverage/", - "dtslint/", - "**/*.d.ts", - "**/*.config.*", - "**/vitest.shared.*" - ] - } - } -} - -export default config